diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index ccad454d8a8..bab7a0059bb 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -30,6 +30,7 @@ from clients.agent_backend import ( ) from configs import dify_config from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom +from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity from core.workflow.nodes.agent_v2.dify_tools_builder import ( WorkflowAgentDifyToolLayersBuilder, WorkflowAgentDifyToolsBuilder, @@ -136,15 +137,16 @@ class AgentAppRuntimeRequestBuilder: append_runtime_warnings(metadata, config_warnings) soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul) knowledge_config = build_knowledge_layer_config(agent_soul) + model_plugin_id, model_provider = normalize_plugin_daemon_provider_identity( + ModelProviderID(agent_soul.model.model_provider), + agent_soul.model.plugin_id, + ) request = self._request_builder.build_for_agent_app( AgentBackendAgentAppRunInput( model=AgentBackendModelConfig( - plugin_id=self._plugin_daemon_plugin_id( - plugin_id=agent_soul.model.plugin_id, - model_provider=agent_soul.model.model_provider, - ), - model_provider=self._plugin_daemon_provider_name(agent_soul.model.model_provider), + plugin_id=model_plugin_id, + model_provider=model_provider, model=agent_soul.model.model, credentials=self._normalize_credentials(credentials), model_settings=agent_soul.model.model_settings.model_dump(mode="json", exclude_none=True), @@ -220,20 +222,6 @@ class AgentAppRuntimeRequestBuilder: "agent_config_snapshot_id": context.agent_config_snapshot_id, } - @staticmethod - def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str: - """Return the transport plugin id expected by plugin-daemon headers.""" - if plugin_id.count("/") == 1: - return plugin_id.split(":", 1)[0].split("@", 1)[0] - if plugin_id: - return ModelProviderID(plugin_id).plugin_id - return ModelProviderID(model_provider).plugin_id - - @staticmethod - def _plugin_daemon_provider_name(model_provider: str) -> str: - """Return the provider name expected by plugin-daemon dispatch payloads.""" - return ModelProviderID(model_provider).provider_name - @staticmethod def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]: normalized: dict[str, str | int | float | bool | None] = {} diff --git a/api/core/plugin/provider_identity.py b/api/core/plugin/provider_identity.py new file mode 100644 index 00000000000..28a1927fc06 --- /dev/null +++ b/api/core/plugin/provider_identity.py @@ -0,0 +1,26 @@ +"""Normalize persisted provider identities for plugin-daemon transport.""" + +from models.provider_ids import GenericProviderID + + +def normalize_plugin_daemon_provider_identity( + provider_id: GenericProviderID, + plugin_id: str | None = None, +) -> tuple[str, str]: + """Return the stable plugin ID and short provider name expected by plugin-daemon. + + Explicit three-segment plugin IDs are legacy persisted provider IDs. They are + normalized through the concrete provider-ID type to preserve model and tool aliases. + """ + if plugin_id: + plugin_id_parts = plugin_id.split("/") + if len(plugin_id_parts) == 3: + normalized_plugin_id = type(provider_id)(plugin_id).plugin_id + elif len(plugin_id_parts) == 2: + normalized_plugin_id = plugin_id.split(":", 1)[0].split("@", 1)[0] + else: + raise ValueError(f"Invalid plugin id {plugin_id}") + else: + normalized_plugin_id = provider_id.plugin_id + normalized_provider_id = GenericProviderID(f"{normalized_plugin_id}/{provider_id.provider_name}") + return normalized_provider_id.plugin_id, provider_id.provider_name diff --git a/api/core/workflow/nodes/agent_v2/dify_tools_builder.py b/api/core/workflow/nodes/agent_v2/dify_tools_builder.py index bf384a42482..31896bf3f8b 100644 --- a/api/core/workflow/nodes/agent_v2/dify_tools_builder.py +++ b/api/core/workflow/nodes/agent_v2/dify_tools_builder.py @@ -18,6 +18,7 @@ from sqlalchemy import select from core.agent.entities import AgentToolEntity from core.app.entities.app_invoke_entities import InvokeFrom +from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity from core.tools.__base.tool import Tool from core.tools.entities.tool_entities import ToolProviderType from core.tools.errors import ToolProviderCredentialValidationError, ToolProviderNotFoundError @@ -389,8 +390,8 @@ class WorkflowAgentDifyToolsBuilder: f"Dify Tool {tool_config.tool_name!r} has no runtime.", ) - provider_id = self._provider_id(tool_config) - plugin_id, provider = self._plugin_provider(tool_config, provider_id) + provider_id = ToolProviderID(self._provider_id(tool_config)) + plugin_id, provider = normalize_plugin_daemon_provider_identity(provider_id, tool_config.plugin_id) parameters = self._prepared_parameters(tool_runtime) runtime_parameters = self._runtime_parameters(tool_runtime, parameters) description = self._description(tool_config, tool_runtime) @@ -427,13 +428,6 @@ class WorkflowAgentDifyToolsBuilder: parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(), ) - @staticmethod - def _plugin_provider(tool_config: AgentSoulDifyToolConfig, provider_id: str) -> tuple[str, str]: - if tool_config.plugin_id and tool_config.provider: - return tool_config.plugin_id, tool_config.provider - provider_id_entity = ToolProviderID(provider_id) - return provider_id_entity.plugin_id, provider_id_entity.provider_name - @staticmethod def _credential_type( tool_config: AgentSoulDifyToolConfig, diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 367dd2e6d42..848fd4f80bc 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -47,6 +47,7 @@ from clients.agent_backend import ( ) from configs import dify_config from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom +from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value from graphon.file import File, FileTransferMethod from graphon.variables.segments import Segment @@ -217,15 +218,16 @@ class WorkflowAgentRuntimeRequestBuilder: soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul) soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip() knowledge_config = build_knowledge_layer_config(agent_soul) + model_plugin_id, model_provider = normalize_plugin_daemon_provider_identity( + ModelProviderID(agent_soul.model.model_provider), + agent_soul.model.plugin_id, + ) request = self._request_builder.build_for_workflow_node( AgentBackendWorkflowNodeRunInput( model=AgentBackendModelConfig( - plugin_id=self._plugin_daemon_plugin_id( - plugin_id=agent_soul.model.plugin_id, - model_provider=agent_soul.model.model_provider, - ), - model_provider=self._plugin_daemon_provider_name(agent_soul.model.model_provider), + plugin_id=model_plugin_id, + model_provider=model_provider, model=agent_soul.model.model, credentials=self._normalize_credentials(credentials), model_settings=agent_soul.model.model_settings.model_dump(mode="json", exclude_none=True), @@ -305,20 +307,6 @@ class WorkflowAgentRuntimeRequestBuilder: return "single_step" return "workflow_run" - @staticmethod - def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str: - """Return the transport plugin id expected by plugin-daemon headers.""" - if plugin_id.count("/") == 1: - return plugin_id.split(":", 1)[0].split("@", 1)[0] - if plugin_id: - return ModelProviderID(plugin_id).plugin_id - return ModelProviderID(model_provider).plugin_id - - @staticmethod - def _plugin_daemon_provider_name(model_provider: str) -> str: - """Return the provider name expected by plugin-daemon dispatch payloads.""" - return ModelProviderID(model_provider).provider_name - @staticmethod def _idempotency_key(context: WorkflowAgentRuntimeBuildContext) -> str: # Stage 4 ยง7 / D-4: retries get distinct keys (``...:retry-{attempt}``) so diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index a027fd31699..1dc9d6933a8 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -325,6 +325,20 @@ class TestAgentAppRuntimeRequestBuilder: assert llm.config.plugin_id == "langgenius/openai" assert llm.config.model_provider == "openai" + def test_build_normalizes_legacy_three_segment_model_plugin_id(self): + soul = _soul_with_model() + soul.model.plugin_id = "langgenius/openai/openai" + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(soul)) + + llm = next(layer for layer in result.request.composition.layers if layer.name == "llm") + assert llm.config.plugin_id == "langgenius/openai" + assert llm.config.model_provider == "openai" + def test_build_maps_agent_soul_knowledge_to_knowledge_layer(self): soul = AgentSoulConfig.model_validate( { diff --git a/api/tests/unit_tests/core/plugin/test_provider_identity.py b/api/tests/unit_tests/core/plugin/test_provider_identity.py new file mode 100644 index 00000000000..91f3378a54d --- /dev/null +++ b/api/tests/unit_tests/core/plugin/test_provider_identity.py @@ -0,0 +1,59 @@ +import pytest + +from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity +from models.provider_ids import ModelProviderID, ToolProviderID + + +def test_normalizes_stable_explicit_plugin_id() -> None: + assert normalize_plugin_daemon_provider_identity( + ModelProviderID("google"), + "langgenius/google", + ) == ("langgenius/google", "google") + + +def test_normalizes_versioned_explicit_plugin_unique_identifier() -> None: + assert normalize_plugin_daemon_provider_identity( + ModelProviderID("google"), + "langgenius/google:0.4.2@checksum", + ) == ("langgenius/google", "google") + + +def test_normalizes_legacy_explicit_provider_id_with_typed_alias_rules() -> None: + assert normalize_plugin_daemon_provider_identity( + ModelProviderID("langgenius/openai/openai"), + "langgenius/openai/openai", + ) == ("langgenius/openai", "openai") + assert normalize_plugin_daemon_provider_identity( + ModelProviderID("langgenius/google/google"), + "langgenius/google/google", + ) == ("langgenius/gemini", "google") + assert normalize_plugin_daemon_provider_identity( + ToolProviderID("langgenius/jina/jina"), + "langgenius/jina/jina", + ) == ("langgenius/jina_tool", "jina") + + +def test_rejects_malformed_explicit_plugin_id() -> None: + with pytest.raises(ValueError, match="Invalid plugin id"): + normalize_plugin_daemon_provider_identity( + ModelProviderID("langgenius/openai/openai"), + "langgenius/openai:0.4.2/extra", + ) + + +def test_derives_plugin_id_when_explicit_plugin_id_is_absent() -> None: + assert normalize_plugin_daemon_provider_identity(ToolProviderID("langgenius/google/google")) == ( + "langgenius/google", + "google", + ) + + +def test_preserves_typed_provider_alias_rules() -> None: + assert normalize_plugin_daemon_provider_identity(ModelProviderID("google")) == ( + "langgenius/gemini", + "google", + ) + assert normalize_plugin_daemon_provider_identity(ToolProviderID("jina")) == ( + "langgenius/jina_tool", + "jina", + ) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py index cf169ca6edf..46ef8fdeb01 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py @@ -262,6 +262,32 @@ def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime(): assert runtime_provider.last_agent_tool.provider_type.value == "plugin" +def test_normalizes_fully_qualified_builtin_plugin_provider_for_daemon_transport(): + runtime_provider = FakeRuntimeProvider(_tool()) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "plugin_id": "langgenius/google", + "provider_id": "langgenius/google/google", + "provider": "langgenius/google/google", + "provider_type": "builtin", + "tool_name": "google_search", + "credential_type": "unauthorized", + } + ] + } + ) + + result = _build(builder, tools) + + assert result is not None + prepared = result.tools[0] + assert prepared.plugin_id == "langgenius/google" + assert prepared.provider == "google" + + def test_builds_core_tool_with_file_llm_parameter(): runtime_provider = FakeRuntimeProvider(_file_tool()) builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index c969b296efb..bd2302e6a6a 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -343,12 +343,22 @@ def test_build_includes_core_tools_layer_returned_by_injected_builder(): assert DIFY_PLUGIN_TOOLS_LAYER_ID not in layers -def test_normalizes_langgenius_model_provider_for_agent_backend_transport(): +@pytest.mark.parametrize( + "plugin_id", + [ + pytest.param( + "langgenius/openai:0.4.2@21195ee1321849e0a7d4b3f6b2fd8c2be23ea6c7182e1b444ecc4c1711b52468", + id="marketplace-unique-identifier", + ), + pytest.param("langgenius/openai/openai", id="legacy-three-segment-provider-id"), + ], +) +def test_normalizes_langgenius_model_provider_for_agent_backend_transport(plugin_id: str): context = _context() context.snapshot.config_snapshot = AgentSoulConfig( prompt={"system_prompt": "You are careful."}, model=AgentSoulModelConfig( - plugin_id="langgenius/openai:0.4.2@21195ee1321849e0a7d4b3f6b2fd8c2be23ea6c7182e1b444ecc4c1711b52468", + plugin_id=plugin_id, model_provider="langgenius/openai/openai", model="gpt-test", ),