diff --git a/api/commands/data_migrate.py b/api/commands/data_migrate.py
index 2b33f46cd8c..4a71d864b26 100644
--- a/api/commands/data_migrate.py
+++ b/api/commands/data_migrate.py
@@ -145,7 +145,7 @@ def legacy_model_types(
option_name="--model-types",
)
selected_model_types = (
- tuple(ModelType.value_of(model_type) for model_type in normalized_model_types)
+ tuple(ModelType(model_type) for model_type in normalized_model_types)
if normalized_model_types
else (
ModelType.LLM,
diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py
index 5cb61d0c7bf..20f117c96a4 100644
--- a/api/core/provider_manager.py
+++ b/api/core/provider_manager.py
@@ -816,7 +816,7 @@ class ProviderManager:
return [
{
"model": model_key[0],
- "model_type": ModelType.value_of(model_key[1]),
+ "model_type": ModelType(model_key[1]),
"available_model_credentials": [
CredentialConfiguration(credential_id=cred.id, credential_name=cred.credential_name)
for cred in creds
diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py
index 3019704dac5..9de26b8214b 100644
--- a/api/core/workflow/workflow_entry.py
+++ b/api/core/workflow/workflow_entry.py
@@ -30,6 +30,7 @@ from graphon.entities import GraphInitParams
from graphon.entities.graph_config import NodeConfigDictAdapter
from graphon.errors import WorkflowNodeRunFailedError
from graphon.file import File
+from graphon.filters import GraphEventFilterContext, ResponseStreamFilter, filter_graph_events
from graphon.graph import Graph
from graphon.graph_engine import GraphEngine, GraphEngineConfig
from graphon.graph_engine.command_channels import CommandChannel, InMemoryChannel
@@ -45,6 +46,21 @@ logger = logging.getLogger(__name__)
_file_access_controller = DatabaseFileAccessController()
+def iter_dify_graph_engine_events(engine: GraphEngine) -> Generator[GraphEngineEvent, None, None]:
+ """
+ Apply Dify's response streaming compatibility filter to GraphEngine events.
+
+ Graphon v0.5.0 emits raw variable stream chunks and requires callers to opt
+ into the legacy response-ordered stream behavior that Dify exposes to its
+ workflow runners and tests.
+ """
+ yield from filter_graph_events(
+ engine.run(),
+ context=GraphEventFilterContext.from_engine(engine),
+ filters=[ResponseStreamFilter()],
+ )
+
+
class _WorkflowChildEngineBuilder:
tenant_id: str
@@ -223,8 +239,8 @@ class WorkflowEntry:
graph_engine = self.graph_engine
try:
- # run workflow
- generator = graph_engine.run()
+ # Preserve Dify's response-stream semantics on top of Graphon 0.5.0.
+ generator = iter_dify_graph_engine_events(graph_engine)
yield from generator
except GenerateTaskStoppedError:
pass
diff --git a/api/models/utils/file_input_compat.py b/api/models/utils/file_input_compat.py
index 04aea9f7f65..938ee6e7dd1 100644
--- a/api/models/utils/file_input_compat.py
+++ b/api/models/utils/file_input_compat.py
@@ -149,10 +149,10 @@ def build_file_from_mapping_without_lookup(*, file_mapping: Mapping[str, Any]) -
def rebuild_serialized_graph_files_without_lookup(value: Any) -> Any:
"""Recursively rebuild serialized graph file payloads into `File` objects.
- `graphon` 0.2.2 no longer accepts legacy serialized file mappings via
- `model_validate_json()`. Dify keeps this recovery path at the model boundary
- so historical JSON blobs remain readable without reintroducing global graph
- patches or test-local coercion.
+ `graphon` no longer accepts legacy serialized file mappings via
+ `model_validate_json()`. Dify keeps this recovery path at the model
+ boundary so historical JSON blobs remain readable without reintroducing
+ global graph patches or test-local coercion.
"""
match value:
case list():
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 380ad55ee99..14f16a8ee59 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -44,7 +44,7 @@ dependencies = [
"resend>=2.27.0,<3.0.0",
# Emerging: newer and fast-moving, use compatible pins
"fastopenapi[flask]==0.7.0",
- "graphon==0.4.0",
+ "graphon==0.5.1",
"httpx-sse==0.4.3",
"json-repair==0.59.4",
]
diff --git a/api/services/model_load_balancing_service.py b/api/services/model_load_balancing_service.py
index 15be7d5af3e..46bf24fffbf 100644
--- a/api/services/model_load_balancing_service.py
+++ b/api/services/model_load_balancing_service.py
@@ -66,7 +66,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# Enable model load balancing
- provider_configuration.enable_model_load_balancing(model=model, model_type=ModelType.value_of(model_type))
+ provider_configuration.enable_model_load_balancing(model=model, model_type=ModelType(model_type))
def disable_model_load_balancing(self, tenant_id: str, provider: str, model: str, model_type: str):
"""
@@ -87,7 +87,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# disable model load balancing
- provider_configuration.disable_model_load_balancing(model=model, model_type=ModelType.value_of(model_type))
+ provider_configuration.disable_model_load_balancing(model=model, model_type=ModelType(model_type))
def get_load_balancing_configs(
self, tenant_id: str, provider: str, model: str, model_type: str, config_from: str = ""
@@ -109,7 +109,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# Convert model type to ModelType
- model_type_enum = ModelType.value_of(model_type)
+ model_type_enum = ModelType(model_type)
# Get provider model setting
provider_model_setting = provider_configuration.get_provider_model_setting(
@@ -250,7 +250,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# Convert model type to ModelType
- model_type_enum = ModelType.value_of(model_type)
+ model_type_enum = ModelType(model_type)
# Get load balancing configurations
load_balancing_model_config = db.session.scalar(
@@ -338,7 +338,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# Convert model type to ModelType
- model_type_enum = ModelType.value_of(model_type)
+ model_type_enum = ModelType(model_type)
if not isinstance(configs, list):
raise ValueError("Invalid load balancing configs")
@@ -524,7 +524,7 @@ class ModelLoadBalancingService:
raise ValueError(f"Provider {provider} does not exist.")
# Convert model type to ModelType
- model_type_enum = ModelType.value_of(model_type)
+ model_type_enum = ModelType(model_type)
load_balancing_model_config = None
if config_id:
diff --git a/api/services/model_provider_service.py b/api/services/model_provider_service.py
index 362aa6103d9..7c34afd42e1 100644
--- a/api/services/model_provider_service.py
+++ b/api/services/model_provider_service.py
@@ -70,7 +70,7 @@ class ModelProviderService:
provider_responses = []
for provider_configuration in provider_configurations.values():
if model_type:
- model_type_entity = ModelType.value_of(model_type)
+ model_type_entity = ModelType(model_type)
if model_type_entity not in provider_configuration.provider.supported_model_types:
continue
@@ -273,7 +273,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
return provider_configuration.get_custom_model_credential(
- model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
+ model_type=ModelType(model_type), model=model, credential_id=credential_id
)
def validate_model_credentials(
@@ -291,7 +291,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.validate_custom_model_credentials(
- model_type=ModelType.value_of(model_type), model=model, credentials=credentials
+ model_type=ModelType(model_type), model=model, credentials=credentials
)
def create_model_credential(
@@ -316,7 +316,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.create_custom_model_credential(
- model_type=ModelType.value_of(model_type),
+ model_type=ModelType(model_type),
model=model,
credentials=credentials,
credential_name=credential_name,
@@ -346,7 +346,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.update_custom_model_credential(
- model_type=ModelType.value_of(model_type),
+ model_type=ModelType(model_type),
model=model,
credentials=credentials,
credential_id=credential_id,
@@ -366,7 +366,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.delete_custom_model_credential(
- model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
+ model_type=ModelType(model_type), model=model, credential_id=credential_id
)
def switch_active_custom_model_credential(
@@ -384,7 +384,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.switch_custom_model_credential(
- model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
+ model_type=ModelType(model_type), model=model, credential_id=credential_id
)
def add_model_credential_to_model_list(
@@ -402,7 +402,7 @@ class ModelProviderService:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
provider_configuration.add_model_credential_to_model(
- model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
+ model_type=ModelType(model_type), model=model, credential_id=credential_id
)
def remove_model(self, tenant_id: str, provider: str, model_type: str, model: str):
@@ -416,7 +416,7 @@ class ModelProviderService:
:return:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
- provider_configuration.delete_custom_model(model_type=ModelType.value_of(model_type), model=model)
+ provider_configuration.delete_custom_model(model_type=ModelType(model_type), model=model)
def get_models_by_model_type(self, tenant_id: str, model_type: str) -> list[ProviderWithModelsResponse]:
"""
@@ -430,7 +430,7 @@ class ModelProviderService:
provider_configurations = self._get_provider_manager(tenant_id).get_configurations(tenant_id)
# Get provider available models
- models = provider_configurations.get_models(model_type=ModelType.value_of(model_type), only_active=True)
+ models = provider_configurations.get_models(model_type=ModelType(model_type), only_active=True)
# Group models by provider
provider_models: dict[str, list[ModelWithProviderEntity]] = {}
@@ -509,7 +509,7 @@ class ModelProviderService:
:param model_type: model type
:return:
"""
- model_type_enum = ModelType.value_of(model_type)
+ model_type_enum = ModelType(model_type)
try:
result = self._get_provider_manager(tenant_id).get_default_model(
@@ -544,7 +544,7 @@ class ModelProviderService:
:param model: model name
:return:
"""
- model_type_enum = ModelType.value_of(model_type)
+ model_type_enum = ModelType(model_type)
self._get_provider_manager(tenant_id).update_default_model_record(
tenant_id=tenant_id, model_type=model_type_enum, provider=provider, model=model
)
@@ -594,7 +594,7 @@ class ModelProviderService:
:return:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
- provider_configuration.enable_model(model=model, model_type=ModelType.value_of(model_type))
+ provider_configuration.enable_model(model=model, model_type=ModelType(model_type))
def disable_model(self, tenant_id: str, provider: str, model: str, model_type: str):
"""
@@ -607,4 +607,4 @@ class ModelProviderService:
:return:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
- provider_configuration.disable_model(model=model, model_type=ModelType.value_of(model_type))
+ provider_configuration.disable_model(model=model, model_type=ModelType(model_type))
diff --git a/api/tests/fixtures/workflow/response_stream_filter_issue_170_workflow.yml b/api/tests/fixtures/workflow/response_stream_filter_issue_170_workflow.yml
new file mode 100644
index 00000000000..f2f6e779c3a
--- /dev/null
+++ b/api/tests/fixtures/workflow/response_stream_filter_issue_170_workflow.yml
@@ -0,0 +1,138 @@
+app:
+ description: Response stream ordering fixture matching graphon issue 170.
+ icon: 🤖
+ icon_background: '#FFEAD5'
+ mode: advanced-chat
+ name: response_stream_filter_issue_170_workflow
+ use_icon_as_answer_icon: false
+dependencies: []
+kind: app
+version: 0.3.1
+workflow:
+ conversation_variables: []
+ environment_variables: []
+ features:
+ file_upload: {}
+ opening_statement: ''
+ retriever_resource:
+ enabled: true
+ sensitive_word_avoidance:
+ enabled: false
+ speech_to_text:
+ enabled: false
+ suggested_questions: []
+ suggested_questions_after_answer:
+ enabled: false
+ text_to_speech:
+ enabled: false
+ language: ''
+ voice: ''
+ graph:
+ edges:
+ - id: start-llm
+ source: start
+ sourceHandle: source
+ target: llm
+ targetHandle: target
+ - id: llm-dufu
+ source: llm
+ sourceHandle: source
+ target: dufu
+ targetHandle: target
+ - id: dufu-answer
+ source: dufu
+ sourceHandle: source
+ target: answer
+ targetHandle: target
+ nodes:
+ - data:
+ desc: ''
+ title: Start
+ type: start
+ variables: []
+ id: start
+ position:
+ x: 80
+ y: 282
+ sourcePosition: right
+ targetPosition: left
+ type: custom
+ - data:
+ context:
+ enabled: false
+ variable_selector: []
+ desc: ''
+ memory:
+ query_prompt_template: '{{#sys.query#}}'
+ window:
+ enabled: false
+ size: 10
+ model:
+ completion_params:
+ temperature: 0.7
+ mode: chat
+ name: gpt-4o-mini
+ provider: openai
+ prompt_template:
+ - role: system
+ text: Please output a poem by Li Bai
+ selected: false
+ title: Li Bai
+ type: llm
+ variables: []
+ vision:
+ enabled: false
+ id: llm
+ position:
+ x: 380
+ y: 282
+ sourcePosition: right
+ targetPosition: left
+ type: custom
+ - data:
+ context:
+ enabled: false
+ variable_selector: []
+ desc: ''
+ model:
+ completion_params:
+ temperature: 0.7
+ mode: chat
+ name: gpt-4o-mini
+ provider: openai
+ prompt_template:
+ - role: system
+ text: Please output a poem by Du Fu
+ selected: false
+ title: Du Fu
+ type: llm
+ variables: []
+ vision:
+ enabled: false
+ id: dufu
+ position:
+ x: 680
+ y: 282
+ sourcePosition: right
+ targetPosition: left
+ type: custom
+ - data:
+ answer: |-
+ # Du Fu
+
+ {{#dufu.text#}}
+
+ # Li Bai
+
+ {{#llm.text#}}
+ desc: ''
+ title: Answer
+ type: answer
+ variables: []
+ id: answer
+ position:
+ x: 980
+ y: 282
+ sourcePosition: right
+ targetPosition: left
+ type: custom
diff --git a/api/tests/integration_tests/workflow/test_response_stream_filter_integration.py b/api/tests/integration_tests/workflow/test_response_stream_filter_integration.py
new file mode 100644
index 00000000000..fd1391849fd
--- /dev/null
+++ b/api/tests/integration_tests/workflow/test_response_stream_filter_integration.py
@@ -0,0 +1,75 @@
+"""Integration coverage for Dify's ResponseStreamFilter boundary behavior."""
+
+from core.workflow.workflow_entry import iter_dify_graph_engine_events
+from graphon.graph_engine import GraphEngine, GraphEngineConfig
+from graphon.graph_engine.command_channels import InMemoryChannel
+from graphon.graph_events import GraphRunSucceededEvent, NodeRunStreamChunkEvent
+from tests.unit_tests.core.workflow.graph_engine.test_mock_config import MockConfigBuilder
+from tests.unit_tests.core.workflow.graph_engine.test_table_runner import WorkflowRunner
+
+
+def _build_issue_170_mock_config():
+ runner = WorkflowRunner()
+ mock_config = (
+ MockConfigBuilder()
+ .with_node_output(
+ "llm",
+ {
+ "text": "Quiet Night Thought",
+ "usage": {
+ "prompt_tokens": 10,
+ "completion_tokens": 5,
+ "total_tokens": 15,
+ },
+ "finish_reason": "stop",
+ },
+ )
+ .with_node_output(
+ "dufu",
+ {
+ "text": "Spring View",
+ "usage": {
+ "prompt_tokens": 10,
+ "completion_tokens": 5,
+ "total_tokens": 15,
+ },
+ "finish_reason": "stop",
+ },
+ )
+ .build()
+ )
+
+ return runner, mock_config
+
+
+def test_dify_response_stream_filter_handles_issue_170_shape() -> None:
+ runner, mock_config = _build_issue_170_mock_config()
+ fixture_data = runner.load_fixture("response_stream_filter_issue_170_workflow")
+ graph, graph_runtime_state = runner.create_graph_from_fixture(
+ fixture_data=fixture_data,
+ query="1",
+ use_mock_factory=True,
+ mock_config=mock_config,
+ )
+
+ expected_answer = "# Du Fu\n\nSpring View\n\n# Li Bai\n\nQuiet Night Thought"
+
+ engine = GraphEngine(
+ workflow_id="test_workflow",
+ graph=graph,
+ graph_runtime_state=graph_runtime_state,
+ command_channel=InMemoryChannel(),
+ config=GraphEngineConfig(),
+ )
+ events = list(iter_dify_graph_engine_events(engine))
+
+ stream_chunk_events = [event for event in events if isinstance(event, NodeRunStreamChunkEvent)]
+ success_events = [event for event in events if isinstance(event, GraphRunSucceededEvent)]
+
+ assert success_events
+ assert stream_chunk_events
+ actual_answer = "".join(event.chunk for event in stream_chunk_events)
+ assert actual_answer.strip() == expected_answer
+ assert stream_chunk_events[-1].is_final is True
+ assert success_events[-1].outputs["answer"].strip() == expected_answer
+ assert actual_answer.strip() == success_events[-1].outputs["answer"].strip()
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py
index a16a8b481a6..a393dab7d9d 100644
--- a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py
@@ -9,7 +9,7 @@ from core.repositories.human_input_repository import (
HumanInputFormEntity,
HumanInputFormRepository,
)
-from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime
+from core.workflow.node_runtime import DifyHumanInputNodeRuntime
from core.workflow.system_variables import build_system_variables
from graphon.entities import WorkflowStartReason
from graphon.file import File, FileTransferMethod, FileType
@@ -186,25 +186,29 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor
)
human_a_config = {"id": "human_a", "data": human_data.model_dump()}
+ human_a_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context)
+ human_a_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
human_a = HumanInputNode(
node_id=human_a_config["id"],
data=human_data,
graph_init_params=graph_init_params,
graph_runtime_state=runtime_state,
form_repository=repo,
- file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context),
- runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context),
+ file_reference_factory=_TestFileReferenceFactory(),
+ runtime=human_a_runtime,
)
human_b_config = {"id": "human_b", "data": human_data.model_dump()}
+ human_b_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context)
+ human_b_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
human_b = HumanInputNode(
node_id=human_b_config["id"],
data=human_data,
graph_init_params=graph_init_params,
graph_runtime_state=runtime_state,
form_repository=repo,
- file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context),
- runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context),
+ file_reference_factory=_TestFileReferenceFactory(),
+ runtime=human_b_runtime,
)
end_data = EndNodeData(
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py
index 100b294f528..e9c9e04e17b 100644
--- a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py
@@ -24,6 +24,7 @@ from core.tools.utils.yaml_utils import _load_yaml_file
from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
+from core.workflow.workflow_entry import iter_dify_graph_engine_events
from graphon.entities import GraphInitParams
from graphon.graph import Graph
from graphon.graph_engine import GraphEngine, GraphEngineConfig
@@ -386,7 +387,7 @@ class TableTestRunner:
# Execute and collect events
events: list[GraphEngineEvent] = []
- for event in engine.run():
+ for event in iter_dify_graph_engine_events(engine):
events.append(event)
# Check execution success
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py b/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py
index ba1e74f3e0e..33e8f869a5a 100644
--- a/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py
@@ -1,3 +1,4 @@
+from core.workflow.workflow_entry import iter_dify_graph_engine_events
from graphon.graph_engine import GraphEngine, GraphEngineConfig
from graphon.graph_engine.command_channels import InMemoryChannel
from graphon.graph_events import (
@@ -31,20 +32,17 @@ def test_tool_in_chatflow():
config=GraphEngineConfig(),
)
- events = list(engine.run())
+ events = list(iter_dify_graph_engine_events(engine))
# Check for successful completion
success_events = [e for e in events if isinstance(e, GraphRunSucceededEvent)]
assert len(success_events) > 0, "Workflow should complete successfully"
- # Check for streaming events
stream_chunk_events = [e for e in events if isinstance(e, NodeRunStreamChunkEvent)]
- stream_chunk_count = len(stream_chunk_events)
-
- assert stream_chunk_count == 1, f"Expected 1 streaming events, but got {stream_chunk_count}"
- assert stream_chunk_events[0].chunk == "hello, dify!", (
- f"Expected chunk to be 'hello, dify!', but got {stream_chunk_events[0].chunk}"
- )
+ assert len(stream_chunk_events) > 0
+ assert "".join(event.chunk for event in stream_chunk_events) == "hello, dify!"
+ assert stream_chunk_events[-1].is_final is True
+ assert success_events[-1].outputs["answer"] == "hello, dify!"
def test_answer_can_render_llm_structured_output_in_chatflow():
@@ -88,7 +86,7 @@ def test_answer_can_render_llm_structured_output_in_chatflow():
config=GraphEngineConfig(),
)
- events = list(engine.run())
+ events = list(iter_dify_graph_engine_events(engine))
success_events = [e for e in events if isinstance(e, GraphRunSucceededEvent)]
assert success_events, "Workflow should complete successfully"
diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py
index 1d68610e7f4..231000817a8 100644
--- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py
+++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py
@@ -30,7 +30,7 @@ from core.workflow.human_input_adapter import (
WebAppDeliveryMethod,
_WebAppDeliveryConfig,
)
-from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime
+from core.workflow.node_runtime import DifyHumanInputNodeRuntime
from core.workflow.system_variables import build_system_variables
from graphon.entities import GraphInitParams
from graphon.file import File, FileTransferMethod, FileType
@@ -171,12 +171,13 @@ def _build_human_input_node(
typed_node_data = (
node_data if isinstance(node_data, HumanInputNodeData) else HumanInputNodeData.model_validate(node_data)
)
+ runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
return HumanInputNode(
node_id=node_id,
data=typed_node_data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
- file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context),
+ file_reference_factory=_TestFileReferenceFactory(),
runtime=runtime,
)
diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py
index 763e1eecfd7..9ee7b79cc42 100644
--- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py
+++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py
@@ -4,7 +4,7 @@ from types import SimpleNamespace
from typing import Any
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom
-from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime
+from core.workflow.node_runtime import DifyHumanInputNodeRuntime
from core.workflow.system_variables import default_system_variables
from graphon.entities import GraphInitParams
from graphon.enums import BuiltinNodeTypes
@@ -67,14 +67,16 @@ def _create_human_input_node(
if isinstance(config["data"], HumanInputNodeData)
else HumanInputNodeData.model_validate(config["data"])
)
+ runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context)
+ runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
return HumanInputNode(
node_id=config["id"],
data=node_data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
form_repository=repo,
- file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context),
- runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context),
+ file_reference_factory=_TestFileReferenceFactory(),
+ runtime=runtime,
)
diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py
index d1fca6564b2..909de623d82 100644
--- a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py
+++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py
@@ -76,6 +76,7 @@ from graphon.nodes.llm.node import (
_render_jinja2_message,
)
from graphon.nodes.llm.protocols import CredentialsProvider, ModelFactory
+from graphon.nodes.llm.reasoning import split_reasoning
from graphon.nodes.llm.runtime_protocols import PromptMessageSerializerProtocol
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.template_rendering import TemplateRenderError
@@ -1271,7 +1272,10 @@ class TestLLMNodeSaveMultiModalImageOutput:
assert llm_node._file_outputs == [mock_file]
assert file == mock_file
mock_file_saver.save_binary_string.assert_called_once_with(
- data=b"test-data", mime_type="image/png", file_type=FileType.IMAGE
+ data=b"test-data",
+ mime_type="image/png",
+ file_type=FileType.IMAGE,
+ extension_override=".png",
)
def test_llm_node_save_url_output(self, llm_node_for_multimodal: tuple[LLMNode, LLMFileSaver]):
@@ -1305,8 +1309,9 @@ class TestLLMNodeSaveMultiModalImageOutput:
def test_llm_node_image_file_to_markdown(llm_node: LLMNode):
mock_file = mock.MagicMock(spec=File)
+ mock_file.type = FileType.IMAGE
mock_file.generate_url.return_value = "https://example.com/image.png"
- markdown = llm_node._image_file_to_markdown(mock_file)
+ markdown = llm_node._saved_file_to_markdown(mock_file)
assert markdown == ""
@@ -1378,6 +1383,7 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown:
data=image_raw_data,
mime_type="image/png",
file_type=FileType.IMAGE,
+ extension_override=".png",
)
assert mock_saved_file in llm_node._file_outputs
@@ -1425,7 +1431,7 @@ class TestReasoningFormat:
Dify is an open source AI platform.
"""
- clean_text, reasoning_content = LLMNode._split_reasoning(text_with_think, "separated")
+ clean_text, reasoning_content = split_reasoning(text_with_think, "separated")
assert clean_text == "Dify is an open source AI platform."
assert reasoning_content == "I need to explain what Dify is. It's an open source AI platform."
@@ -1438,7 +1444,7 @@ class TestReasoningFormat:
Dify is an open source AI platform.
"""
- clean_text, reasoning_content = LLMNode._split_reasoning(text_with_think, "tagged")
+ clean_text, reasoning_content = split_reasoning(text_with_think, "tagged")
# Original text unchanged
assert clean_text == text_with_think
@@ -1450,7 +1456,7 @@ class TestReasoningFormat:
text_without_think = "This is a simple answer without any thinking blocks."
- clean_text, reasoning_content = LLMNode._split_reasoning(text_without_think, "separated")
+ clean_text, reasoning_content = split_reasoning(text_without_think, "separated")
assert clean_text == text_without_think
assert reasoning_content == ""
@@ -1471,7 +1477,7 @@ class TestReasoningFormat:
I need to explain what Dify is. It's an open source AI platform.
Dify is an open source AI platform.
"""
- clean_text, reasoning_content = LLMNode._split_reasoning(text_with_think, node_data.reasoning_format)
+ clean_text, reasoning_content = split_reasoning(text_with_think, node_data.reasoning_format)
assert clean_text == text_with_think
assert reasoning_content == ""
@@ -1569,10 +1575,10 @@ def test_handle_invoke_result_streaming_collects_text_metrics_and_structured_out
)
assert events[0] == first_chunk
- assert events[1] == StreamChunkEvent(selector=["node-1", "text"], chunk="plan", is_final=False)
- assert events[2] == StreamChunkEvent(selector=["node-1", "text"], chunk="answer", is_final=False)
- completed = events[3]
+ assert events[1] == StreamChunkEvent(selector=["node-1", "text"], chunk="answer", is_final=False)
+
+ completed = events[2]
assert isinstance(completed, ModelInvokeCompletedEvent)
assert completed.text == "answer"
assert completed.reasoning_content == "plan"
diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py
index a57cdd13379..3ccfdf76f5a 100644
--- a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py
+++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py
@@ -338,6 +338,52 @@ class TestWorkflowEntryRun:
assert list(entry.run()) == []
+ def test_iter_dify_graph_engine_events_applies_response_stream_filter(self):
+ graph_engine = MagicMock()
+ graph_engine.run.return_value = iter([sentinel.raw_event])
+
+ with (
+ patch.object(
+ workflow_entry.GraphEventFilterContext,
+ "from_engine",
+ return_value=sentinel.filter_context,
+ ) as from_engine,
+ patch.object(
+ workflow_entry,
+ "ResponseStreamFilter",
+ return_value=sentinel.response_stream_filter,
+ ) as response_stream_filter_cls,
+ patch.object(
+ workflow_entry,
+ "filter_graph_events",
+ return_value=iter([sentinel.filtered_event]),
+ ) as filter_graph_events,
+ ):
+ events = list(workflow_entry.iter_dify_graph_engine_events(graph_engine))
+
+ assert events == [sentinel.filtered_event]
+ from_engine.assert_called_once_with(graph_engine)
+ response_stream_filter_cls.assert_called_once_with()
+ filter_graph_events.assert_called_once_with(
+ graph_engine.run.return_value,
+ context=sentinel.filter_context,
+ filters=[sentinel.response_stream_filter],
+ )
+
+ def test_run_delegates_to_dify_event_iterator(self):
+ entry = object.__new__(workflow_entry.WorkflowEntry)
+ entry.graph_engine = sentinel.graph_engine
+
+ with patch.object(
+ workflow_entry,
+ "iter_dify_graph_engine_events",
+ return_value=iter([sentinel.filtered_event]),
+ ) as iter_dify_graph_engine_events:
+ events = list(entry.run())
+
+ assert events == [sentinel.filtered_event]
+ iter_dify_graph_engine_events.assert_called_once_with(sentinel.graph_engine)
+
def test_run_emits_failed_event_for_unexpected_errors(self):
entry = object.__new__(workflow_entry.WorkflowEntry)
entry.graph_engine = MagicMock()
diff --git a/api/tests/unit_tests/services/test_model_load_balancing_service.py b/api/tests/unit_tests/services/test_model_load_balancing_service.py
index beecf73caa4..827567f1afe 100644
--- a/api/tests/unit_tests/services/test_model_load_balancing_service.py
+++ b/api/tests/unit_tests/services/test_model_load_balancing_service.py
@@ -112,6 +112,29 @@ def test_enable_disable_model_load_balancing_should_call_provider_configuration_
)
+@pytest.mark.parametrize(
+ ("method_name", "expected_provider_method"),
+ [
+ ("enable_model_load_balancing", "enable_model_load_balancing"),
+ ("disable_model_load_balancing", "disable_model_load_balancing"),
+ ],
+)
+def test_enable_disable_model_load_balancing_uses_model_type_constructor_directly(
+ method_name: str,
+ expected_provider_method: str,
+ service: ModelLoadBalancingService,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ provider_configuration = _build_provider_configuration(provider_schema=_build_provider_credential_schema())
+ service.provider_manager.get_configurations.return_value = {"openai": provider_configuration}
+
+ getattr(service, method_name)("tenant-1", "openai", "gpt-4o-mini", "text-generation")
+
+ getattr(provider_configuration, expected_provider_method).assert_called_once_with(
+ model="gpt-4o-mini", model_type=ModelType.LLM
+ )
+
+
@pytest.mark.parametrize(
"method_name",
["enable_model_load_balancing", "disable_model_load_balancing"],
diff --git a/api/tests/unit_tests/services/test_model_provider_service.py b/api/tests/unit_tests/services/test_model_provider_service.py
index 9e4eeb2d6ed..806be013497 100644
--- a/api/tests/unit_tests/services/test_model_provider_service.py
+++ b/api/tests/unit_tests/services/test_model_provider_service.py
@@ -368,6 +368,70 @@ class TestModelProviderServiceDelegation:
if method_name == "get_model_credential":
assert result == {"api_key": "x"}
+ @pytest.mark.parametrize(
+ ("method_name", "method_kwargs", "provider_method_name", "expected_kwargs"),
+ [
+ (
+ "get_model_credential",
+ {
+ "tenant_id": "tenant-1",
+ "provider": "openai",
+ "model_type": "text-generation",
+ "model": "gpt-4o",
+ "credential_id": "cred-1",
+ },
+ "get_custom_model_credential",
+ {"model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1"},
+ ),
+ (
+ "create_model_credential",
+ {
+ "tenant_id": "tenant-1",
+ "provider": "openai",
+ "model_type": "text-generation",
+ "model": "gpt-4o",
+ "credentials": {"api_key": "x"},
+ "credential_name": "cred-a",
+ },
+ "create_custom_model_credential",
+ {
+ "model_type": ModelType.LLM,
+ "model": "gpt-4o",
+ "credentials": {"api_key": "x"},
+ "credential_name": "cred-a",
+ },
+ ),
+ (
+ "remove_model",
+ {
+ "tenant_id": "tenant-1",
+ "provider": "openai",
+ "model_type": "text-generation",
+ "model": "gpt-4o",
+ },
+ "delete_custom_model",
+ {"model_type": ModelType.LLM, "model": "gpt-4o"},
+ ),
+ ],
+ )
+ def test_custom_model_methods_use_model_type_constructor_directly(
+ self,
+ method_name: str,
+ method_kwargs: dict[str, Any],
+ provider_method_name: str,
+ expected_kwargs: dict[str, Any],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ service = ModelProviderService()
+ provider_configuration = MagicMock()
+ get_provider_config_mock = MagicMock(return_value=provider_configuration)
+ monkeypatch.setattr(service, "_get_provider_configuration", get_provider_config_mock)
+
+ getattr(service, method_name)(**method_kwargs)
+
+ get_provider_config_mock.assert_called_once_with("tenant-1", "openai")
+ getattr(provider_configuration, provider_method_name).assert_called_once_with(**expected_kwargs)
+
class TestModelProviderServiceListingsAndDefaults:
def test_get_models_by_model_type_should_group_active_non_deprecated_models(self) -> None:
diff --git a/api/uv.lock b/api/uv.lock
index 6b979782297..55c2505225a 100644
--- a/api/uv.lock
+++ b/api/uv.lock
@@ -1293,7 +1293,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" },
- { name = "graphon", marker = "extra == 'server'", specifier = "==0.2.2" },
+ { name = "graphon", marker = "extra == 'server'", specifier = "==0.5.1" },
{ name = "grpclib", extras = ["protobuf"], marker = "extra == 'grpc'", specifier = ">=0.4.9,<0.5.0" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
@@ -1636,7 +1636,7 @@ requires-dist = [
{ name = "gmpy2", specifier = ">=2.3.0,<3.0.0" },
{ name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" },
{ name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" },
- { name = "graphon", specifier = "==0.4.0" },
+ { name = "graphon", specifier = "==0.5.1" },
{ name = "gunicorn", specifier = ">=26.0.0,<27.0.0" },
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
{ name = "httpx-sse", specifier = "==0.4.3" },
@@ -2991,7 +2991,7 @@ httpx = [
[[package]]
name = "graphon"
-version = "0.4.0"
+version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
@@ -3012,9 +3012,9 @@ dependencies = [
{ name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] },
{ name = "webvtt-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/76/24/eb1e7983404dcac84816b76ea450e1bb97023e55e00c699d609340bc361e/graphon-0.4.0.tar.gz", hash = "sha256:afb0c7a58f89e09cfa585296429b4d08cd0df80b9ac54d550f88e7d76ec48ee0", size = 261812, upload-time = "2026-05-13T11:48:39.198Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/fa/432fa802bcb13f7f51dc323ddef92594b15333eafef181d937ffa554116e/graphon-0.5.1.tar.gz", hash = "sha256:ca38cc62ef3fbc2f3072b68235bcb41e32a6369a1753b46418c1d761c57125fe", size = 269741, upload-time = "2026-06-11T03:01:38.197Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/de/bad6b3fd1e4b4defc16e6ea106e55c44725a159f1d191a99877bce1c9931/graphon-0.4.0-py3-none-any.whl", hash = "sha256:b33f95886da823d5b1b53d663a4f5f8fa383c37740f3bd19297b8d140fcb804c", size = 372711, upload-time = "2026-05-13T11:48:37.712Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/c5/61e8634b89c320af9453083213e8be436071634dbc69cb14b5fe646763e4/graphon-0.5.1-py3-none-any.whl", hash = "sha256:70b49c244a46fb6e338905210cc895bd67584d9ab1412f6ba3cd4ed284010091", size = 381866, upload-time = "2026-06-11T03:01:36.693Z" },
]
[[package]]
diff --git a/dify-agent/Makefile b/dify-agent/Makefile
index a9b258fa5f9..0decb13ae3d 100644
--- a/dify-agent/Makefile
+++ b/dify-agent/Makefile
@@ -33,7 +33,7 @@ typecheck:
@uv --directory "$(PROJECT_DIR)" run --project . basedpyright --level error src examples tests
test:
- @uv --directory "$(PROJECT_DIR)" run --project . python -m pytest tests
+ @uv --directory "$(PROJECT_DIR)" run --project . --extra server python -m pytest tests
update-examples:
@uv --directory "$(PROJECT_DIR)" run --project . python -m pytest --update-examples tests/docs/test_examples.py
diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml
index e274d9144ff..915114d2338 100644
--- a/dify-agent/pyproject.toml
+++ b/dify-agent/pyproject.toml
@@ -17,13 +17,10 @@ dify-agent = "dify_agent.agent_stub.cli.main:main"
dify-agent-stub-server = "dify_agent.agent_stub.server.cli:main"
[project.optional-dependencies]
-grpc = [
- "grpclib[protobuf]>=0.4.9,<0.5.0",
- "protobuf>=6.33.5,<7.0.0",
-]
+grpc = ["grpclib[protobuf]>=0.4.9,<0.5.0", "protobuf>=6.33.5,<7.0.0"]
server = [
"fastapi==0.136.0",
- "graphon==0.2.2",
+ "graphon==0.5.1",
"jsonschema>=4.23.0,<5.0.0",
"jwcrypto>=1.5.6,<2",
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
@@ -35,22 +32,14 @@ server = [
[tool.setuptools.packages.find]
where = ["src"]
-include = [
- "agenton*",
- "agenton_collections*",
- "dify_agent*",
-]
+include = ["agenton*", "agenton_collections*", "dify_agent*"]
[tool.pyright]
include = ["src", "examples", "tests"]
venvPath = "."
venv = ".venv"
pythonVersion = "3.12"
-extraPaths = [
- "src",
- "examples/agenton",
- "examples/dify_agent",
-]
+extraPaths = ["src", "examples/agenton", "examples/dify_agent"]
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -59,12 +48,7 @@ python_files = ["test_*.py", "*_test.py"]
[tool.ruff]
line-length = 120
target-version = "py312"
-include = [
- "src/**/*.py",
- "examples/**/*.py",
- "tests/**/*.py",
- "docs/**/*.py",
-]
+include = ["src/**/*.py", "examples/**/*.py", "tests/**/*.py", "docs/**/*.py"]
[dependency-groups]
dev = [
diff --git a/dify-agent/tests/local/dify_agent/layers/execution_context/__init__.py b/dify-agent/tests/local/dify_agent/layers/execution_context/__init__.py
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/dify-agent/tests/local/dify_agent/layers/execution_context/__init__.py
@@ -0,0 +1 @@
+
diff --git a/dify-agent/tests/local/dify_agent/layers/shell/__init__.py b/dify-agent/tests/local/dify_agent/layers/shell/__init__.py
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/dify-agent/tests/local/dify_agent/layers/shell/__init__.py
@@ -0,0 +1 @@
+
diff --git a/dify-agent/tests/local/test_packaging.py b/dify-agent/tests/local/test_packaging.py
index 6de34442af6..23ae6e65e8b 100644
--- a/dify-agent/tests/local/test_packaging.py
+++ b/dify-agent/tests/local/test_packaging.py
@@ -16,7 +16,7 @@ CLIENT_SHARED_DTO_DEPENDENCIES = {
SERVER_RUNTIME_DEPENDENCIES = {
"fastapi==0.136.0",
- "graphon==0.2.2",
+ "graphon==0.5.1",
"jsonschema>=4.23.0,<5.0.0",
"jwcrypto>=1.5.6,<2",
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock
index 69b54dc1137..0ee1bf4f8bf 100644
--- a/dify-agent/uv.lock
+++ b/dify-agent/uv.lock
@@ -628,7 +628,7 @@ docs = [
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" },
- { name = "graphon", marker = "extra == 'server'", specifier = "==0.2.2" },
+ { name = "graphon", marker = "extra == 'server'", specifier = "==0.5.1" },
{ name = "grpclib", extras = ["protobuf"], marker = "extra == 'grpc'", specifier = ">=0.4.9,<0.5.0" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
@@ -808,7 +808,7 @@ wheels = [
[[package]]
name = "graphon"
-version = "0.2.2"
+version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
@@ -829,9 +829,9 @@ dependencies = [
{ name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] },
{ name = "webvtt-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/08/50/e745a79c5f742f88f6011a1f7c9ba2c2f9cc1beedd982f0b192f1ab8c748/graphon-0.2.2.tar.gz", hash = "sha256:141f0de536171850f1af6f738dc66f0285aadd3c097f1dad2a038636789e0aa5", size = 236360, upload-time = "2026-04-17T08:52:28.047Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/fa/432fa802bcb13f7f51dc323ddef92594b15333eafef181d937ffa554116e/graphon-0.5.1.tar.gz", hash = "sha256:ca38cc62ef3fbc2f3072b68235bcb41e32a6369a1753b46418c1d761c57125fe", size = 269741, upload-time = "2026-06-11T03:01:38.197Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/de/89/a6340afdaf5169d17a318e00fc685fb67ed99baa602c2cbbbf6af6a76096/graphon-0.2.2-py3-none-any.whl", hash = "sha256:754e544d08779138f99eac6547ab08559463680e2c76488b05e1c978210392b4", size = 340808, upload-time = "2026-04-17T08:52:26.5Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/c5/61e8634b89c320af9453083213e8be436071634dbc69cb14b5fe646763e4/graphon-0.5.1-py3-none-any.whl", hash = "sha256:70b49c244a46fb6e338905210cc895bd67584d9ab1412f6ba3cd4ed284010091", size = 381866, upload-time = "2026-06-11T03:01:36.693Z" },
]
[[package]]