From b4c50eb920b1cc99221f758faeeef7ed19bb1040 Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Thu, 11 Jun 2026 15:37:27 +0800 Subject: [PATCH] chore(api): Upgrade graphon to v0.5.1 (#37168) Co-authored-by: Yunlu Wen Co-authored-by: L1nSn0w --- api/commands/data_migrate.py | 2 +- api/core/provider_manager.py | 2 +- api/core/workflow/workflow_entry.py | 20 ++- api/models/utils/file_input_compat.py | 8 +- api/pyproject.toml | 2 +- api/services/model_load_balancing_service.py | 12 +- api/services/model_provider_service.py | 28 ++-- ...ponse_stream_filter_issue_170_workflow.yml | 138 ++++++++++++++++++ ...test_response_stream_filter_integration.py | 75 ++++++++++ .../test_parallel_human_input_join_resume.py | 14 +- .../graph_engine/test_table_runner.py | 3 +- .../graph_engine/test_tool_in_chatflow.py | 16 +- .../nodes/human_input/test_entities.py | 5 +- .../test_human_input_form_filled_event.py | 8 +- .../core/workflow/nodes/llm/test_node.py | 24 +-- .../workflow/test_workflow_entry_helpers.py | 46 ++++++ .../test_model_load_balancing_service.py | 23 +++ .../services/test_model_provider_service.py | 64 ++++++++ api/uv.lock | 10 +- dify-agent/Makefile | 2 +- dify-agent/pyproject.toml | 26 +--- .../layers/execution_context/__init__.py | 1 + .../local/dify_agent/layers/shell/__init__.py | 1 + dify-agent/tests/local/test_packaging.py | 2 +- dify-agent/uv.lock | 8 +- 25 files changed, 450 insertions(+), 90 deletions(-) create mode 100644 api/tests/fixtures/workflow/response_stream_filter_issue_170_workflow.yml create mode 100644 api/tests/integration_tests/workflow/test_response_stream_filter_integration.py create mode 100644 dify-agent/tests/local/dify_agent/layers/execution_context/__init__.py create mode 100644 dify-agent/tests/local/dify_agent/layers/shell/__init__.py 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 == "![](https://example.com/image.png)" @@ -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]]