fix(agent): normalize plugin provider identities (#40619)

Co-authored-by: Joel <iamjoel007@gmail.com>
This commit is contained in:
盐粒 Yanli 2026-08-13 07:22:41 +00:00 committed by GitHub
parent fb30dd2d58
commit 79b8d7a797
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 154 additions and 49 deletions

View File

@ -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] = {}

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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(
{

View File

@ -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",
)

View File

@ -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)

View File

@ -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",
),