From 787a26251aa9a6ce992bd4682e81ce14b345c83b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Tue, 20 Oct 2026 13:29:35 +0800 Subject: [PATCH 1/9] fix(plugin): pin daemon dispatches to package identifiers --- api/core/agent/strategy/plugin.py | 7 +- .../datasource/__base/datasource_provider.py | 3 +- .../online_document/online_document_plugin.py | 4 +- .../online_drive/online_drive_plugin.py | 4 +- .../website_crawl/website_crawl_plugin.py | 2 +- api/core/plugin/impl/base.py | 9 + api/core/plugin/impl/model.py | 55 ++++- api/core/plugin/impl/model_runtime.py | 20 +- api/core/tools/plugin_tool/provider.py | 2 +- api/core/tools/plugin_tool/tool.py | 4 +- api/core/trigger/provider.py | 12 +- .../nodes/agent_v2/dify_tools_builder.py | 7 + api/factories/agent_factory.py | 4 +- api/services/plugin/dispatch_identity.py | 22 ++ .../agent_backend/test_request_builder.py | 1 + .../core/agent/strategy/test_plugin.py | 3 + .../agent_app/test_runtime_request_builder.py | 1 + .../app/apps/completion/test_app_runner.py | 15 +- .../__base/test_datasource_provider.py | 2 + .../core/plugin/impl/test_model_client.py | 211 +++++++++++++++--- .../core/plugin/test_model_runtime_adapter.py | 22 +- .../unit_tests/core/tools/test_plugin_tool.py | 4 +- .../core/tools/test_plugin_tool_provider.py | 2 +- .../nodes/agent_v2/test_dify_tools_builder.py | 8 +- .../agent_v2/test_runtime_request_builder.py | 1 + .../services/plugin/test_dispatch_identity.py | 66 ++++++ .../user-manual/plugin-tool-layer/index.md | 6 + .../run_server_consumer.py | 1 + .../run_server_sync_client.py | 1 + .../dify_agent/layers/dify_plugin/configs.py | 4 + .../layers/dify_plugin/tool_client.py | 21 +- .../layers/dify_plugin/tools_layer.py | 8 +- .../layers/execution_context/layer.py | 8 +- .../layers/dify_plugin/test_configs.py | 11 +- .../layers/dify_plugin/test_layers.py | 47 ++++ 35 files changed, 521 insertions(+), 77 deletions(-) create mode 100644 api/services/plugin/dispatch_identity.py create mode 100644 api/tests/unit_tests/services/plugin/test_dispatch_identity.py diff --git a/api/core/agent/strategy/plugin.py b/api/core/agent/strategy/plugin.py index 83859e98b43428..c901a4730f1ad1 100644 --- a/api/core/agent/strategy/plugin.py +++ b/api/core/agent/strategy/plugin.py @@ -18,10 +18,13 @@ class PluginAgentStrategy(BaseAgentStrategy): declaration: AgentStrategyEntity meta_version: str | None = None - def __init__(self, tenant_id: str, declaration: AgentStrategyEntity, meta_version: str | None): + def __init__( + self, tenant_id: str, declaration: AgentStrategyEntity, meta_version: str | None, plugin_unique_identifier: str + ): self.tenant_id = tenant_id self.declaration = declaration self.meta_version = meta_version + self.plugin_unique_identifier = plugin_unique_identifier @override def get_parameters(self) -> Sequence[AgentStrategyParameter]: @@ -48,7 +51,7 @@ def _invoke( """ Invoke the agent strategy. """ - manager = PluginAgentClient() + manager = PluginAgentClient(plugin_unique_identifier=self.plugin_unique_identifier) initialized_params = self.initialize_parameters(params) params = convert_parameters_to_plugin_format(initialized_params) diff --git a/api/core/datasource/__base/datasource_provider.py b/api/core/datasource/__base/datasource_provider.py index d5633d9f08161a..c5674627694bd9 100644 --- a/api/core/datasource/__base/datasource_provider.py +++ b/api/core/datasource/__base/datasource_provider.py @@ -11,6 +11,7 @@ class DatasourcePluginProviderController(ABC): entity: DatasourceProviderEntityWithPlugin tenant_id: str + plugin_unique_identifier: str def __init__(self, entity: DatasourceProviderEntityWithPlugin, tenant_id: str) -> None: self.entity = entity @@ -29,7 +30,7 @@ def _validate_credentials(self, user_id: str, credentials: dict[str, Any]) -> No """ validate the credentials of the provider """ - manager = PluginToolManager() + manager = PluginToolManager(plugin_unique_identifier=self.plugin_unique_identifier) if not manager.validate_datasource_credentials( tenant_id=self.tenant_id, user_id=user_id, diff --git a/api/core/datasource/online_document/online_document_plugin.py b/api/core/datasource/online_document/online_document_plugin.py index e857736dda5a1e..a422191d75ea1e 100644 --- a/api/core/datasource/online_document/online_document_plugin.py +++ b/api/core/datasource/online_document/online_document_plugin.py @@ -37,7 +37,7 @@ def get_online_document_pages( datasource_parameters: dict[str, Any], provider_type: str, ) -> Generator[OnlineDocumentPagesMessage]: - manager = PluginDatasourceManager() + manager = PluginDatasourceManager(plugin_unique_identifier=self.plugin_unique_identifier) return manager.get_online_document_pages( tenant_id=self.tenant_id, @@ -55,7 +55,7 @@ def get_online_document_page_content( datasource_parameters: GetOnlineDocumentPageContentRequest, provider_type: str, ) -> Generator[DatasourceMessage]: - manager = PluginDatasourceManager() + manager = PluginDatasourceManager(plugin_unique_identifier=self.plugin_unique_identifier) return manager.get_online_document_page_content( tenant_id=self.tenant_id, diff --git a/api/core/datasource/online_drive/online_drive_plugin.py b/api/core/datasource/online_drive/online_drive_plugin.py index 1e0710568befd6..e19769368ee1b2 100644 --- a/api/core/datasource/online_drive/online_drive_plugin.py +++ b/api/core/datasource/online_drive/online_drive_plugin.py @@ -38,7 +38,7 @@ def online_drive_browse_files( request: OnlineDriveBrowseFilesRequest, provider_type: str, ) -> Generator[OnlineDriveBrowseFilesResponse]: - manager = PluginDatasourceManager() + manager = PluginDatasourceManager(plugin_unique_identifier=self.plugin_unique_identifier) return manager.online_drive_browse_files( tenant_id=self.tenant_id, @@ -56,7 +56,7 @@ def online_drive_download_file( request: OnlineDriveDownloadFileRequest, provider_type: str, ) -> Generator[DatasourceMessage]: - manager = PluginDatasourceManager() + manager = PluginDatasourceManager(plugin_unique_identifier=self.plugin_unique_identifier) return manager.online_drive_download_file( tenant_id=self.tenant_id, diff --git a/api/core/datasource/website_crawl/website_crawl_plugin.py b/api/core/datasource/website_crawl/website_crawl_plugin.py index 3f2d02a3ea8f82..62a2e2d7429999 100644 --- a/api/core/datasource/website_crawl/website_crawl_plugin.py +++ b/api/core/datasource/website_crawl/website_crawl_plugin.py @@ -35,7 +35,7 @@ def get_website_crawl( datasource_parameters: Mapping[str, Any], provider_type: str, ) -> Generator[WebsiteCrawlMessage]: - manager = PluginDatasourceManager() + manager = PluginDatasourceManager(plugin_unique_identifier=self.plugin_unique_identifier) return manager.get_website_crawl( tenant_id=self.tenant_id, diff --git a/api/core/plugin/impl/base.py b/api/core/plugin/impl/base.py index 97dab3ac176a26..a3b44bdb6d1334 100644 --- a/api/core/plugin/impl/base.py +++ b/api/core/plugin/impl/base.py @@ -118,6 +118,11 @@ def _normalize_plugin_daemon_response_for_type(json_response: Any, type_: type[o class BasePluginClient: + def __init__(self, *, plugin_unique_identifier: str | None = None) -> None: + if plugin_unique_identifier == "": + raise ValueError("Plugin unique identifier must not be empty") + self._plugin_unique_identifier = plugin_unique_identifier + def _request( self, method: str, @@ -180,6 +185,10 @@ def _prepare_request( prepared_headers["X-Api-Key"] = dify_config.PLUGIN_DAEMON_KEY prepared_headers.setdefault("Accept-Encoding", "gzip, deflate, br") + from services.plugin.dispatch_identity import prepare_dispatch_identity + + prepare_dispatch_identity(path, prepared_headers, self._plugin_unique_identifier) + # Inject traceparent header for distributed tracing self._inject_trace_headers(prepared_headers) diff --git a/api/core/plugin/impl/model.py b/api/core/plugin/impl/model.py index 8ee73be38685c5..52cf2aaae15ce3 100644 --- a/api/core/plugin/impl/model.py +++ b/api/core/plugin/impl/model.py @@ -68,6 +68,8 @@ def get_model_schema( model_type: str, model: str, credentials: dict[str, Any], + *, + plugin_unique_identifier: str, ) -> AIModelEntity | None: """ Get model schema @@ -87,6 +89,7 @@ def get_model_schema( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -97,7 +100,14 @@ def get_model_schema( return None def validate_provider_credentials( - self, tenant_id: str, user_id: str | None, plugin_id: str, provider: str, credentials: dict[str, Any] + self, + tenant_id: str, + user_id: str | None, + plugin_id: str, + provider: str, + credentials: dict[str, Any], + *, + plugin_unique_identifier: str, ) -> bool: """ validate the credentials of the provider @@ -115,6 +125,7 @@ def validate_provider_credentials( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -136,6 +147,8 @@ def validate_model_credentials( model_type: str, model: str, credentials: dict[str, Any], + *, + plugin_unique_identifier: str, ) -> bool: """ validate the credentials of the provider @@ -155,6 +168,7 @@ def validate_model_credentials( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -181,6 +195,8 @@ def invoke_llm( stop: list[str] | None = None, stream: bool = True, app_id: str | None = None, + *, + plugin_unique_identifier: str, ) -> Generator[LLMResultChunk]: """ Invoke llm @@ -208,6 +224,7 @@ def invoke_llm( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -230,6 +247,8 @@ def start_llm_polling( tools: list[PromptMessageTool] | None = None, stop: list[str] | None = None, json_schema: dict[str, Any] | None = None, + *, + plugin_unique_identifier: str, ) -> LLMPollingResult: """Start an LLM polling request for plugin-backed long-running jobs.""" try: @@ -256,6 +275,7 @@ def start_llm_polling( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -272,6 +292,8 @@ def check_llm_polling( model: str, credentials: dict[str, Any], plugin_state: dict[str, Any], + *, + plugin_unique_identifier: str, ) -> LLMPollingResult: """Check the latest state for a plugin-backed LLM polling job.""" try: @@ -293,6 +315,7 @@ def check_llm_polling( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -325,6 +348,8 @@ def get_llm_num_tokens( credentials: dict[str, Any], prompt_messages: list[PromptMessage], tools: list[PromptMessageTool] | None = None, + *, + plugin_unique_identifier: str, ) -> int: """ Get number of tokens for llm @@ -348,6 +373,7 @@ def get_llm_num_tokens( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -367,6 +393,8 @@ def invoke_text_embedding( credentials: dict[str, Any], texts: list[str], input_type: str, + *, + plugin_unique_identifier: str, ) -> EmbeddingResult: """ Invoke text embedding @@ -390,6 +418,7 @@ def invoke_text_embedding( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -409,6 +438,8 @@ def invoke_multimodal_embedding( credentials: dict[str, Any], documents: list[dict], input_type: str, + *, + plugin_unique_identifier: str, ) -> EmbeddingResult: """ Invoke file embedding @@ -432,6 +463,7 @@ def invoke_multimodal_embedding( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -450,6 +482,8 @@ def get_text_embedding_num_tokens( model: str, credentials: dict[str, Any], texts: list[str], + *, + plugin_unique_identifier: str, ) -> list[int]: """ Get number of tokens for text embedding @@ -472,6 +506,7 @@ def get_text_embedding_num_tokens( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -493,6 +528,8 @@ def invoke_rerank( docs: list[str], score_threshold: float | None = None, top_n: int | None = None, + *, + plugin_unique_identifier: str, ) -> RerankResult: """ Invoke rerank @@ -518,6 +555,7 @@ def invoke_rerank( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -539,6 +577,8 @@ def invoke_multimodal_rerank( docs: list[MultimodalRerankInput], score_threshold: float | None = None, top_n: int | None = None, + *, + plugin_unique_identifier: str, ) -> RerankResult: """ Invoke multimodal rerank @@ -564,6 +604,7 @@ def invoke_multimodal_rerank( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -582,6 +623,8 @@ def invoke_tts( credentials: dict[str, Any], content_text: str, voice: str, + *, + plugin_unique_identifier: str, ) -> Generator[TTSAudioChunk]: """ Invoke tts @@ -606,6 +649,7 @@ def invoke_tts( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -626,6 +670,8 @@ def get_tts_model_voices( model: str, credentials: dict[str, Any], language: str | None = None, + *, + plugin_unique_identifier: str, ) -> list[TTSModelVoice]: """ Get tts model voices @@ -648,6 +694,7 @@ def get_tts_model_voices( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -670,6 +717,8 @@ def invoke_speech_to_text( model: str, credentials: dict[str, Any], file: IO[bytes], + *, + plugin_unique_identifier: str, ) -> str: """ Invoke speech to text @@ -692,6 +741,7 @@ def invoke_speech_to_text( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) @@ -710,6 +760,8 @@ def invoke_moderation( model: str, credentials: dict[str, Any], text: str, + *, + plugin_unique_identifier: str, ) -> bool: """ Invoke moderation @@ -732,6 +784,7 @@ def invoke_moderation( ), headers={ "X-Plugin-ID": plugin_id, + "X-Plugin-Unique-Identifier": plugin_unique_identifier, "Content-Type": "application/json", }, ) diff --git a/api/core/plugin/impl/model_runtime.py b/api/core/plugin/impl/model_runtime.py index 15f876b82d85db..3ded8562ac92d8 100644 --- a/api/core/plugin/impl/model_runtime.py +++ b/api/core/plugin/impl/model_runtime.py @@ -27,7 +27,6 @@ ) from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType -from graphon.model_runtime.entities.provider_entities import ProviderEntity from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput, RerankResult from graphon.model_runtime.entities.text_embedding_entities import EmbeddingInputType, EmbeddingResult from graphon.model_runtime.model_providers.base.large_language_model import normalize_non_stream_runtime_result @@ -185,6 +184,7 @@ def validate_provider_credentials(self, *, provider: str, credentials: dict[str, tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, credentials=credentials, ) @@ -203,6 +203,7 @@ def validate_model_credentials( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model_type=model_type.value, model=model, @@ -256,6 +257,7 @@ def get_model_schema( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model_type=model_type.value, model=model, @@ -328,6 +330,7 @@ def invoke_llm( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -342,6 +345,7 @@ def invoke_llm( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -448,6 +452,7 @@ def get_llm_num_tokens( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model_type=model_type.value, model=model, @@ -474,6 +479,7 @@ def start_llm_polling( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -498,6 +504,7 @@ def check_llm_polling( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -521,6 +528,7 @@ def invoke_text_embedding( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -545,6 +553,7 @@ def invoke_multimodal_embedding( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -566,6 +575,7 @@ def get_text_embedding_num_tokens( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -591,6 +601,7 @@ def invoke_rerank( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -619,6 +630,7 @@ def invoke_multimodal_rerank( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -645,6 +657,7 @@ def invoke_tts( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -668,6 +681,7 @@ def get_tts_model_voices( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -690,6 +704,7 @@ def invoke_speech_to_text( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, @@ -712,13 +727,14 @@ def invoke_moderation( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, + plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, provider=provider_name, model=model, credentials=credentials, text=text, ) - def _get_provider_schema(self, provider: str) -> ProviderEntity: + def _get_provider_schema(self, provider: str) -> PluginModelProviderDeclaration: providers = self.fetch_model_providers() provider_entity = next((item for item in providers if item.provider == provider), None) if provider_entity is None: diff --git a/api/core/tools/plugin_tool/provider.py b/api/core/tools/plugin_tool/provider.py index 526b3680b07f85..6c40b95b8255a7 100644 --- a/api/core/tools/plugin_tool/provider.py +++ b/api/core/tools/plugin_tool/provider.py @@ -39,7 +39,7 @@ def _validate_credentials(self, user_id: str, credentials: dict[str, Any]): """ validate the credentials of the provider """ - manager = PluginToolManager() + manager = PluginToolManager(plugin_unique_identifier=self.plugin_unique_identifier) if not manager.validate_provider_credentials( tenant_id=self.tenant_id, user_id=user_id, diff --git a/api/core/tools/plugin_tool/tool.py b/api/core/tools/plugin_tool/tool.py index c5e9e7a40eeeed..6d0c58492c382c 100644 --- a/api/core/tools/plugin_tool/tool.py +++ b/api/core/tools/plugin_tool/tool.py @@ -36,7 +36,7 @@ def _invoke( app_id: str | None = None, message_id: str | None = None, ) -> Generator[ToolInvokeMessage]: - manager = PluginToolManager() + manager = PluginToolManager(plugin_unique_identifier=self.plugin_unique_identifier) tool_parameters = convert_parameters_to_plugin_format(tool_parameters) @@ -79,7 +79,7 @@ def get_runtime_parameters( if self.runtime_parameters is not None: return self.runtime_parameters - manager = PluginToolManager() + manager = PluginToolManager(plugin_unique_identifier=self.plugin_unique_identifier) self.runtime_parameters = manager.get_runtime_parameters( tenant_id=self.tenant_id, user_id="", diff --git a/api/core/trigger/provider.py b/api/core/trigger/provider.py index 893526b5e0674c..42f8e88d1650bd 100644 --- a/api/core/trigger/provider.py +++ b/api/core/trigger/provider.py @@ -171,7 +171,7 @@ def validate_credentials(self, user_id: str, credentials: Mapping[str, str]) -> raise TriggerProviderCredentialValidationError(f"Missing required credential field: {config.name}") # Then validate with the plugin daemon - manager = PluginTriggerClient() + manager = PluginTriggerClient(plugin_unique_identifier=self.plugin_unique_identifier) provider_id = self.get_provider_id() response = manager.validate_provider_credentials( tenant_id=self.tenant_id, @@ -282,7 +282,7 @@ def dispatch( :param credential_type: Credential type :return: Dispatch response with triggers and raw HTTP response """ - manager = PluginTriggerClient() + manager = PluginTriggerClient(plugin_unique_identifier=self.plugin_unique_identifier) provider_id: TriggerProviderID = self.get_provider_id() response: TriggerDispatchResponse = manager.dispatch_event( @@ -318,7 +318,7 @@ def invoke_trigger_event( :param payload: Payload :return: Trigger execution result """ - manager = PluginTriggerClient() + manager = PluginTriggerClient(plugin_unique_identifier=self.plugin_unique_identifier) provider_id: TriggerProviderID = self.get_provider_id() return manager.invoke_trigger_event( @@ -352,7 +352,7 @@ def subscribe_trigger( :param credential_type: Credential type :return: Subscription result """ - manager = PluginTriggerClient() + manager = PluginTriggerClient(plugin_unique_identifier=self.plugin_unique_identifier) provider_id: TriggerProviderID = self.get_provider_id() response: TriggerSubscriptionResponse = manager.subscribe( @@ -379,7 +379,7 @@ def unsubscribe_trigger( :param credential_type: Credential type :return: Unsubscribe result """ - manager = PluginTriggerClient() + manager = PluginTriggerClient(plugin_unique_identifier=self.plugin_unique_identifier) provider_id: TriggerProviderID = self.get_provider_id() response: TriggerSubscriptionResponse = manager.unsubscribe( @@ -403,7 +403,7 @@ def refresh_trigger( :param credentials: Provider credentials :return: Refreshed subscription result """ - manager = PluginTriggerClient() + manager = PluginTriggerClient(plugin_unique_identifier=self.plugin_unique_identifier) provider_id: TriggerProviderID = self.get_provider_id() response: TriggerSubscriptionResponse = manager.refresh( 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 95274618d348ed..e0e9008d661cc7 100644 --- a/api/core/workflow/nodes/agent_v2/dify_tools_builder.py +++ b/api/core/workflow/nodes/agent_v2/dify_tools_builder.py @@ -22,6 +22,7 @@ from core.tools.__base.tool import Tool from core.tools.entities.tool_entities import ToolProviderType from core.tools.errors import ToolProviderCredentialValidationError, ToolProviderNotFoundError +from core.tools.plugin_tool.tool import PluginTool from core.tools.tool_manager import ToolManager from core.tools.workflow_as_tool.provider import WorkflowToolProviderController from extensions.ext_database import db @@ -391,12 +392,18 @@ def _to_plugin_backend_tool_config( provider_id = ToolProviderID(self._provider_id(tool_config)) plugin_id, provider = normalize_plugin_daemon_provider_identity(provider_id, tool_config.plugin_id) + if not isinstance(tool_runtime, PluginTool) or not tool_runtime.plugin_unique_identifier: + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_config_invalid", + f"Dify Tool {tool_config.tool_name!r} has no plugin installation identity.", + ) parameters = self._prepared_parameters(tool_runtime) runtime_parameters = self._runtime_parameters(tool_runtime, parameters) description = self._description(tool_config, tool_runtime) return DifyPluginToolConfig( plugin_id=plugin_id, + plugin_unique_identifier=tool_runtime.plugin_unique_identifier, provider=provider, tool_name=exposed_name, credential_type=self._credential_type(tool_config, runtime.credentials), diff --git a/api/factories/agent_factory.py b/api/factories/agent_factory.py index 2570bc22f1cf73..9c13c315a447b1 100644 --- a/api/factories/agent_factory.py +++ b/api/factories/agent_factory.py @@ -10,6 +10,8 @@ def get_plugin_agent_strategy( agent_provider = manager.fetch_agent_strategy_provider(tenant_id, agent_strategy_provider_name) for agent_strategy in agent_provider.declaration.strategies: if agent_strategy.identity.name == agent_strategy_name: - return PluginAgentStrategy(tenant_id, agent_strategy, agent_provider.meta.version) + return PluginAgentStrategy( + tenant_id, agent_strategy, agent_provider.meta.version, agent_provider.plugin_unique_identifier + ) raise ValueError(f"Agent strategy {agent_strategy_name} not found") diff --git a/api/services/plugin/dispatch_identity.py b/api/services/plugin/dispatch_identity.py new file mode 100644 index 00000000000000..be33dc8d74ac2b --- /dev/null +++ b/api/services/plugin/dispatch_identity.py @@ -0,0 +1,22 @@ +"""Bind daemon dispatches to the plugin declaration used by the caller.""" + +PLUGIN_IDENTIFIER_HEADER = "X-Plugin-Unique-Identifier" + + +def prepare_dispatch_identity(path: str, headers: dict[str, str], identifier: str | None) -> None: + parts = path.split("/") + if len(parts) < 4 or parts[0] != "plugin" or parts[2] != "dispatch": + return + + plugin_id = headers.get("X-Plugin-ID") + if not plugin_id: + raise ValueError("X-Plugin-ID is required for plugin dispatch") + + identifier = headers.get(PLUGIN_IDENTIFIER_HEADER) or identifier + if identifier is None: + # Legacy calls leave installation resolution to the daemon. + return + + if identifier.partition(":")[0] != plugin_id: + raise ValueError("Plugin unique identifier does not match X-Plugin-ID") + headers[PLUGIN_IDENTIFIER_HEADER] = identifier diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index c6f2b7724a3235..e11bdbe0eeb732 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -207,6 +207,7 @@ def test_request_builder_adds_dify_plugin_tools_layer_when_configured() -> None: runtime_parameters={}, parameters=[], parameters_json_schema={"type": "object", "properties": {}, "required": []}, + plugin_unique_identifier="langgenius/time:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] ) diff --git a/api/tests/unit_tests/core/agent/strategy/test_plugin.py b/api/tests/unit_tests/core/agent/strategy/test_plugin.py index d4459273e0ed85..c86a46c6bcfeb2 100644 --- a/api/tests/unit_tests/core/agent/strategy/test_plugin.py +++ b/api/tests/unit_tests/core/agent/strategy/test_plugin.py @@ -47,6 +47,7 @@ def strategy(mock_declaration: MagicMock) -> PluginAgentStrategy: return PluginAgentStrategy( tenant_id="tenant_123", declaration=mock_declaration, + plugin_unique_identifier="org/plugin:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", meta_version="v1", ) @@ -61,6 +62,7 @@ def test_init_sets_attributes(self, mock_declaration: MagicMock) -> None: strategy = PluginAgentStrategy( tenant_id="tenant_test", declaration=mock_declaration, + plugin_unique_identifier="org/plugin:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", meta_version="meta_v", ) @@ -72,6 +74,7 @@ def test_init_meta_version_none(self, mock_declaration: MagicMock) -> None: strategy = PluginAgentStrategy( tenant_id="tenant_test", declaration=mock_declaration, + plugin_unique_identifier="org/plugin:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", meta_version=None, ) 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 f7a122427f4458..fc6a2a82580cf1 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 @@ -134,6 +134,7 @@ def build_layers(self, **kwargs): runtime_parameters={}, parameters=[], parameters_json_schema={"type": "object", "properties": {}, "required": []}, + plugin_unique_identifier="langgenius/time:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] ), diff --git a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py index f337af850fb398..c365bfff259353 100644 --- a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py @@ -26,6 +26,7 @@ from core.entities.provider_configuration import ProviderModelBundle from core.entities.provider_entities import CustomProviderConfiguration from core.moderation.base import ModerationError +from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration from core.plugin.impl.model import PluginModelClient from core.plugin.impl.model_runtime import PluginModelRuntime from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime @@ -117,6 +118,14 @@ def model_runtime(monkeypatch: pytest.MonkeyPatch) -> tuple[ProviderModelBundle, provider_schema=bundle.configuration.provider, model_runtime=create_plugin_model_runtime(tenant_id=TENANT_ID), ) + provider_declaration = PluginModelProviderDeclaration.model_validate( + { + **bundle.configuration.provider.model_dump(), + "plugin_unique_identifier": "langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "installation_source": None, + } + ) + monkeypatch.setattr(PluginModelRuntime, "fetch_model_providers", lambda _runtime: [provider_declaration]) calls: list[RecordedCall] = [] events: list[str] = [] @@ -469,7 +478,11 @@ def handle_result(invoke_result: LLMResult | Generator[LLMResultChunk], **_kwarg "method": "POST", "path": f"plugin/{TENANT_ID}/dispatch/llm/invoke", "type_": LLMResultChunk, - "headers": {"X-Plugin-ID": "langgenius/openai", "Content-Type": "application/json"}, + "headers": { + "X-Plugin-ID": "langgenius/openai", + "X-Plugin-Unique-Identifier": "langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "Content-Type": "application/json", + }, "data": { "app_id": APP_ID, "data": { diff --git a/api/tests/unit_tests/core/datasource/__base/test_datasource_provider.py b/api/tests/unit_tests/core/datasource/__base/test_datasource_provider.py index 4592b3763f244b..1b26decb945127 100644 --- a/api/tests/unit_tests/core/datasource/__base/test_datasource_provider.py +++ b/api/tests/unit_tests/core/datasource/__base/test_datasource_provider.py @@ -18,6 +18,8 @@ class ConcreteDatasourcePluginProviderController(DatasourcePluginProviderControl Concrete implementation of DatasourcePluginProviderController for testing purposes. """ + plugin_unique_identifier = "langgenius/example:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + def get_datasource(self, datasource_name: str) -> DatasourcePlugin: raise AssertionError(f"Credential validation must not fetch datasource {datasource_name!r}") diff --git a/api/tests/unit_tests/core/plugin/impl/test_model_client.py b/api/tests/unit_tests/core/plugin/impl/test_model_client.py index 3de7a2f061aeba..ca5965eb21c703 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_model_client.py +++ b/api/tests/unit_tests/core/plugin/impl/test_model_client.py @@ -13,6 +13,8 @@ from graphon.model_runtime.entities.llm_entities import LLMPollingResult, LLMPollingStatus, LLMResult, LLMUsage from graphon.model_runtime.entities.message_entities import AssistantPromptMessage +PLUGIN_IDENTIFIER = "org/plugin:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + class TestPluginModelClient: def test_fetch_model_providers(self, mocker: MockerFixture): @@ -53,11 +55,12 @@ def test_get_model_schema(self, mocker: MockerFixture): result = client.get_model_schema( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model_type="llm", model="gpt-test", credentials={"api_key": "key"}, + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result is schema @@ -67,7 +70,16 @@ def test_get_model_schema_empty_stream_returns_none(self, mocker: MockerFixture) client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) - result = client.get_model_schema("tenant-1", "user-1", "org/plugin:1", "provider-a", "llm", "gpt-test", {}) + result = client.get_model_schema( + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "llm", + "gpt-test", + {}, + plugin_unique_identifier=PLUGIN_IDENTIFIER, + ) assert result is None @@ -83,9 +95,10 @@ def test_validate_provider_credentials(self, mocker: MockerFixture): result = client.validate_provider_credentials( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", credentials=credentials, + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result is True @@ -104,7 +117,14 @@ def test_validate_provider_credentials_without_dict_update(self, mocker: MockerF ) credentials = {"api_key": "same"} - result = client.validate_provider_credentials("tenant-1", "user-1", "org/plugin:1", "provider-a", credentials) + result = client.validate_provider_credentials( + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + credentials, + plugin_unique_identifier=PLUGIN_IDENTIFIER, + ) assert result is False assert credentials == {"api_key": "same"} @@ -113,7 +133,17 @@ def test_validate_provider_credentials_empty_returns_false(self, mocker: MockerF client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) - assert client.validate_provider_credentials("tenant-1", "user-1", "org/plugin:1", "provider-a", {}) is False + assert ( + client.validate_provider_credentials( + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + {}, + plugin_unique_identifier=PLUGIN_IDENTIFIER, + ) + is False + ) def test_validate_model_credentials(self, mocker: MockerFixture): client = PluginModelClient() @@ -127,11 +157,12 @@ def test_validate_model_credentials(self, mocker: MockerFixture): result = client.validate_model_credentials( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model_type="llm", model="gpt-test", credentials=credentials, + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result is True @@ -146,7 +177,16 @@ def test_validate_model_credentials_empty_returns_false(self, mocker: MockerFixt mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) assert ( - client.validate_model_credentials("tenant-1", "user-1", "org/plugin:1", "provider-a", "llm", "gpt-test", {}) + client.validate_model_credentials( + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "llm", + "gpt-test", + {}, + plugin_unique_identifier=PLUGIN_IDENTIFIER, + ) is False ) @@ -160,7 +200,7 @@ def test_invoke_llm(self, mocker: MockerFixture): client.invoke_llm( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="gpt-test", credentials={"api_key": "key"}, @@ -170,6 +210,7 @@ def test_invoke_llm(self, mocker: MockerFixture): stop=["STOP"], stream=False, app_id="app-1", + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) ) @@ -188,11 +229,12 @@ def test_invoke_llm_omits_app_id_when_missing(self, mocker: MockerFixture): client.invoke_llm( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="gpt-test", credentials={}, prompt_messages=[], + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) ) @@ -212,11 +254,12 @@ def _boom(): client.invoke_llm( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="gpt-test", credentials={}, prompt_messages=[], + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) ) @@ -236,7 +279,7 @@ def test_start_llm_polling(self, mocker: MockerFixture): result = client.start_llm_polling( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="gpt-test", credentials={"api_key": "key"}, @@ -245,6 +288,7 @@ def test_start_llm_polling(self, mocker: MockerFixture): tools=[], stop=["STOP"], json_schema={"type": "object"}, + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result == polling_result @@ -283,11 +327,12 @@ def test_check_llm_polling(self, mocker: MockerFixture): result = client.check_llm_polling( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="gpt-test", credentials={"api_key": "key"}, plugin_state={"task_id": "poll-1"}, + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result == polling_result @@ -320,11 +365,12 @@ def test_start_llm_polling_maps_unsupported_polling_invoke_error(self, mocker: M client.start_llm_polling( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="gpt-test", credentials={"api_key": "key"}, prompt_messages=[], + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) def test_get_llm_num_tokens(self, mocker: MockerFixture): @@ -338,13 +384,14 @@ def test_get_llm_num_tokens(self, mocker: MockerFixture): result = client.get_llm_num_tokens( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model_type="llm", model="gpt-test", credentials={}, prompt_messages=[], tools=[], + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result == 42 @@ -354,7 +401,17 @@ def test_get_llm_num_tokens_empty_returns_zero(self, mocker: MockerFixture): mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) assert ( - client.get_llm_num_tokens("tenant-1", "user-1", "org/plugin:1", "provider-a", "llm", "gpt-test", {}, []) + client.get_llm_num_tokens( + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "llm", + "gpt-test", + {}, + [], + plugin_unique_identifier=PLUGIN_IDENTIFIER, + ) == 0 ) @@ -368,12 +425,13 @@ def test_invoke_text_embedding(self, mocker: MockerFixture): result = client.invoke_text_embedding( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="embedding-a", credentials={}, texts=["hello"], input_type="search_document", + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result is embedding_result @@ -384,7 +442,15 @@ def test_invoke_text_embedding_empty_raises(self, mocker: MockerFixture): with pytest.raises(ValueError, match="Failed to invoke text embedding"): client.invoke_text_embedding( - "tenant-1", "user-1", "org/plugin:1", "provider-a", "embedding-a", {}, ["hello"], "x" + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "embedding-a", + {}, + ["hello"], + "x", + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) def test_invoke_multimodal_embedding(self, mocker: MockerFixture): @@ -397,12 +463,13 @@ def test_invoke_multimodal_embedding(self, mocker: MockerFixture): result = client.invoke_multimodal_embedding( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="embedding-a", credentials={}, documents=[{"type": "image", "value": "abc"}], input_type="search_document", + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result is embedding_result @@ -413,7 +480,15 @@ def test_invoke_multimodal_embedding_empty_raises(self, mocker: MockerFixture): with pytest.raises(ValueError, match="Failed to invoke file embedding"): client.invoke_multimodal_embedding( - "tenant-1", "user-1", "org/plugin:1", "provider-a", "embedding-a", {}, [{"type": "image"}], "x" + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "embedding-a", + {}, + [{"type": "image"}], + "x", + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) def test_get_text_embedding_num_tokens(self, mocker: MockerFixture): @@ -425,7 +500,14 @@ def test_get_text_embedding_num_tokens(self, mocker: MockerFixture): ) assert client.get_text_embedding_num_tokens( - "tenant-1", "user-1", "org/plugin:1", "provider-a", "embedding-a", {}, ["a"] + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "embedding-a", + {}, + ["a"], + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) == [ 1, 2, @@ -438,7 +520,14 @@ def test_get_text_embedding_num_tokens_empty_returns_list(self, mocker: MockerFi assert ( client.get_text_embedding_num_tokens( - "tenant-1", "user-1", "org/plugin:1", "provider-a", "embedding-a", {}, ["a"] + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "embedding-a", + {}, + ["a"], + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) == [] ) @@ -451,7 +540,7 @@ def test_invoke_rerank(self, mocker: MockerFixture): result = client.invoke_rerank( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="rerank-a", credentials={}, @@ -459,6 +548,7 @@ def test_invoke_rerank(self, mocker: MockerFixture): docs=["doc-1"], score_threshold=0.2, top_n=5, + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result is rerank_result @@ -468,7 +558,17 @@ def test_invoke_rerank_empty_raises(self, mocker: MockerFixture): mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) with pytest.raises(ValueError, match="Failed to invoke rerank"): - client.invoke_rerank("tenant-1", "user-1", "org/plugin:1", "provider-a", "rerank-a", {}, "q", ["doc-1"]) + client.invoke_rerank( + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "rerank-a", + {}, + "q", + ["doc-1"], + plugin_unique_identifier=PLUGIN_IDENTIFIER, + ) def test_invoke_multimodal_rerank(self, mocker: MockerFixture): client = PluginModelClient() @@ -478,7 +578,7 @@ def test_invoke_multimodal_rerank(self, mocker: MockerFixture): result = client.invoke_multimodal_rerank( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="rerank-a", credentials={}, @@ -486,6 +586,7 @@ def test_invoke_multimodal_rerank(self, mocker: MockerFixture): docs=[{"type": "image", "value": "doc"}], score_threshold=0.1, top_n=3, + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result is rerank_result @@ -498,12 +599,13 @@ def test_invoke_multimodal_rerank_empty_raises(self, mocker: MockerFixture): client.invoke_multimodal_rerank( "tenant-1", "user-1", - "org/plugin:1", + "org/plugin", "provider-a", "rerank-a", {}, {"type": "text"}, [{"type": "image"}], + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) def test_invoke_tts(self, mocker: MockerFixture): @@ -523,12 +625,13 @@ def test_invoke_tts(self, mocker: MockerFixture): client.invoke_tts( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="tts-a", credentials={}, content_text="hello", voice="alloy", + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) ) @@ -545,7 +648,19 @@ def _boom(): mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=_boom()) with pytest.raises(ValueError, match="tts error-400"): - list(client.invoke_tts("tenant-1", "user-1", "org/plugin:1", "provider-a", "tts-a", {}, "hello", "alloy")) + list( + client.invoke_tts( + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "tts-a", + {}, + "hello", + "alloy", + plugin_unique_identifier=PLUGIN_IDENTIFIER, + ) + ) def test_get_tts_model_voices(self, mocker: MockerFixture): client = PluginModelClient() @@ -567,11 +682,12 @@ def test_get_tts_model_voices(self, mocker: MockerFixture): result = client.get_tts_model_voices( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="tts-a", credentials={}, language="en", + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result == [{"name": "Alloy", "value": "alloy"}, {"name": "Echo", "value": "echo"}] @@ -580,7 +696,18 @@ def test_get_tts_model_voices_empty_returns_list(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) - assert client.get_tts_model_voices("tenant-1", "user-1", "org/plugin:1", "provider-a", "tts-a", {}) == [] + assert ( + client.get_tts_model_voices( + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "tts-a", + {}, + plugin_unique_identifier=PLUGIN_IDENTIFIER, + ) + == [] + ) def test_invoke_speech_to_text(self, mocker: MockerFixture): client = PluginModelClient() @@ -593,11 +720,12 @@ def test_invoke_speech_to_text(self, mocker: MockerFixture): result = client.invoke_speech_to_text( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="stt-a", credentials={}, file=io.BytesIO(b"abc"), + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result == "transcribed text" @@ -609,7 +737,14 @@ def test_invoke_speech_to_text_empty_raises(self, mocker: MockerFixture): with pytest.raises(ValueError, match="Failed to invoke speech to text"): client.invoke_speech_to_text( - "tenant-1", "user-1", "org/plugin:1", "provider-a", "stt-a", {}, io.BytesIO(b"abc") + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "stt-a", + {}, + io.BytesIO(b"abc"), + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) def test_invoke_moderation(self, mocker: MockerFixture): @@ -623,11 +758,12 @@ def test_invoke_moderation(self, mocker: MockerFixture): result = client.invoke_moderation( tenant_id="tenant-1", user_id="user-1", - plugin_id="org/plugin:1", + plugin_id="org/plugin", provider="provider-a", model="moderation-a", credentials={}, text="safe text", + plugin_unique_identifier=PLUGIN_IDENTIFIER, ) assert result is True @@ -638,4 +774,13 @@ def test_invoke_moderation_empty_raises(self, mocker: MockerFixture): mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) with pytest.raises(ValueError, match="Failed to invoke moderation"): - client.invoke_moderation("tenant-1", "user-1", "org/plugin:1", "provider-a", "moderation-a", {}, "unsafe") + client.invoke_moderation( + "tenant-1", + "user-1", + "org/plugin", + "provider-a", + "moderation-a", + {}, + "unsafe", + plugin_unique_identifier=PLUGIN_IDENTIFIER, + ) diff --git a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py index 2e20eb941e6718..659c27f9072ec4 100644 --- a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py +++ b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py @@ -41,7 +41,7 @@ def _recording_model_client( ]: """Use a real client with isolated provider I/O and exact invocation records.""" client = PluginModelClient() - results: dict[str, object] = {} + results: dict[str, object] = {"fetch_model_providers": [_build_plugin_model_provider(tenant_id="tenant")]} calls: dict[str, list[tuple[tuple[object, ...], dict[str, object]]]] = defaultdict(list) def recorder(method: Callable[..., object]) -> Callable[..., object]: @@ -119,7 +119,7 @@ def _build_plugin_model_provider(*, tenant_id: str, provider: str = "openai") -> updated_at=datetime.datetime.now(), provider=provider, tenant_id=tenant_id, - plugin_unique_identifier=f"langgenius/{provider}/{provider}", + plugin_unique_identifier=f"langgenius/{provider}:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", plugin_id=f"langgenius/{provider}", installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( @@ -143,7 +143,7 @@ def test_fetch_model_providers_returns_runtime_entities(self, monkeypatch: pytes updated_at=datetime.datetime.now(), provider="openai", tenant_id="tenant", - plugin_unique_identifier="langgenius/openai/openai", + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", plugin_id="langgenius/openai", installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( @@ -191,7 +191,7 @@ def test_fetch_model_providers_only_exposes_short_name_for_canonical_provider( updated_at=datetime.datetime.now(), provider="openai", tenant_id="tenant", - plugin_unique_identifier="langgenius/openai/openai", + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", plugin_id="langgenius/openai", installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( @@ -256,6 +256,7 @@ def test_validate_provider_credentials_resolves_plugin_fields(self, monkeypatch: plugin_id="langgenius/openai", provider="openai", credentials={"api_key": "secret"}, + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] @@ -317,6 +318,7 @@ def test_invoke_llm_resolves_plugin_fields(self, monkeypatch: pytest.MonkeyPatch tools=None, stop=None, stream=False, + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] @@ -352,6 +354,7 @@ def test_invoke_llm_forwards_string_app_id_from_request_metadata(self, monkeypat stop=None, stream=True, app_id="app-1", + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] @@ -406,6 +409,7 @@ def test_invoke_llm_returns_plugin_stream_directly(self, monkeypatch: pytest.Mon tools=None, stop=["END"], stream=True, + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] @@ -444,6 +448,7 @@ def test_start_llm_polling_resolves_plugin_fields(self, monkeypatch: pytest.Monk tools=None, stop=["END"], json_schema={"type": "object"}, + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] @@ -478,6 +483,7 @@ def test_check_llm_polling_resolves_plugin_fields(self, monkeypatch: pytest.Monk model="gpt-4o-mini", credentials={"api_key": "secret"}, plugin_state={"task_id": "poll-1"}, + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] @@ -527,6 +533,7 @@ def test_invoke_tts_uses_bound_runtime_user_when_runtime_is_unbound(self, monkey credentials={"api_key": "secret"}, content_text="hello", voice="alloy", + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] @@ -864,6 +871,7 @@ def setex(key: str, ttl: int, value: str) -> None: model_type=ModelType.LLM.value, model="gpt-4o-mini", credentials={"api_key": "secret"}, + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] assert writes == [(deleted[0], 300, schema.model_dump_json())] @@ -900,7 +908,7 @@ def test_get_provider_icon_reads_requested_variant_and_detects_svg_mime(monkeypa updated_at=datetime.datetime.now(), provider="openai", tenant_id="tenant", - plugin_unique_identifier="langgenius/openai/openai", + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", plugin_id="langgenius/openai", installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( @@ -942,7 +950,7 @@ def test_get_provider_icon_rejects_unsupported_types_and_missing_variants(monkey updated_at=datetime.datetime.now(), provider="openai", tenant_id="tenant", - plugin_unique_identifier="langgenius/openai/openai", + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", plugin_id="langgenius/openai", installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( @@ -1075,7 +1083,7 @@ def test_get_provider_schema_supports_short_alias_and_rejects_invalid_provider(m updated_at=datetime.datetime.now(), provider="openai", tenant_id="tenant", - plugin_unique_identifier="langgenius/openai/openai", + plugin_unique_identifier="langgenius/openai:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", plugin_id="langgenius/openai", installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( diff --git a/api/tests/unit_tests/core/tools/test_plugin_tool.py b/api/tests/unit_tests/core/tools/test_plugin_tool.py index 0806f71ea4d3d6..dc49d18bf5c423 100644 --- a/api/tests/unit_tests/core/tools/test_plugin_tool.py +++ b/api/tests/unit_tests/core/tools/test_plugin_tool.py @@ -39,7 +39,7 @@ def _build_plugin_tool(*, has_runtime_parameters: bool) -> PluginTool: runtime=runtime, tenant_id="tenant-1", icon="icon.svg", - plugin_unique_identifier="plugin-uid", + plugin_unique_identifier="langgenius/provider-a:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) @@ -73,7 +73,7 @@ def handle_request(request: httpx.Request) -> httpx.Response: forked = tool.fork_tool_runtime(ToolRuntime(tenant_id="tenant-2")) assert isinstance(forked, PluginTool) assert forked.runtime.tenant_id == "tenant-2" - assert forked.plugin_unique_identifier == "plugin-uid" + assert forked.plugin_unique_identifier == tool.plugin_unique_identifier def test_plugin_tool_get_runtime_parameters_branches(): diff --git a/api/tests/unit_tests/core/tools/test_plugin_tool_provider.py b/api/tests/unit_tests/core/tools/test_plugin_tool_provider.py index c3442098103eed..c903b8aa6e4b93 100644 --- a/api/tests/unit_tests/core/tools/test_plugin_tool_provider.py +++ b/api/tests/unit_tests/core/tools/test_plugin_tool_provider.py @@ -44,7 +44,7 @@ def _build_controller() -> PluginToolProviderController: return PluginToolProviderController( entity=entity, plugin_id="plugin-id", - plugin_unique_identifier="plugin-uid", + plugin_unique_identifier="langgenius/provider-a:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", tenant_id="tenant-1", ) 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 cdd2c69ea06d47..6b1677d0726c86 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 @@ -18,6 +18,7 @@ ToolParameter, ToolProviderType, ) +from core.tools.plugin_tool.tool import PluginTool from core.tools.tool_manager import ToolManager from core.workflow.nodes.agent_v2.dify_tools_builder import ( WorkflowAgentDifyToolsBuilder, @@ -69,7 +70,12 @@ def get_agent_tool_runtime( return self.tool -class FakeTool(Tool): +class FakeTool(PluginTool): + plugin_unique_identifier = "langgenius/search:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + def __init__(self, entity: ToolEntity, runtime: ToolRuntime) -> None: + super().__init__(entity, runtime, runtime.tenant_id, "icon.svg", self.plugin_unique_identifier) + def tool_provider_type(self): raise NotImplementedError 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 afaeadd5e3d661..2fd7ab6096ba07 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 @@ -123,6 +123,7 @@ def build_layers(self, *, tenant_id, app_id, user_id, tools, invoke_from): runtime_parameters={}, parameters=[], parameters_json_schema={"type": "object", "properties": {}, "required": []}, + plugin_unique_identifier="langgenius/time:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] ), diff --git a/api/tests/unit_tests/services/plugin/test_dispatch_identity.py b/api/tests/unit_tests/services/plugin/test_dispatch_identity.py new file mode 100644 index 00000000000000..f4655b97cd6183 --- /dev/null +++ b/api/tests/unit_tests/services/plugin/test_dispatch_identity.py @@ -0,0 +1,66 @@ +"""Dispatch identity remains bound to the caller across installation changes.""" + +import json +from unittest.mock import Mock + +import httpx +import pytest + +from core.plugin.impl import base as plugin_client_module +from core.plugin.impl.base import BasePluginClient +from core.plugin.plugin_service import PluginService +from services.plugin.dispatch_identity import PLUGIN_IDENTIFIER_HEADER, prepare_dispatch_identity + +PLUGIN_ID = "langgenius/example" +IDENTIFIER = f"{PLUGIN_ID}:1.0.0@{'a' * 32}" + + +@pytest.mark.parametrize("stream", [False, True]) +def test_dispatch_keeps_declared_version_without_refetch_or_invalidation( + monkeypatch: pytest.MonkeyPatch, stream: bool +) -> None: + lookup = Mock(side_effect=AssertionError("Pinned calls must not resolve the current installation")) + invalidate = Mock(side_effect=AssertionError("Dispatch must not invalidate configuration")) + monkeypatch.setattr(PluginService, "list_installations_from_ids", lookup) + monkeypatch.setattr(PluginService, "invalidate_plugin_model_providers_cache", invalidate) + requests: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, text='data: {"code": 0, "message": "", "data": true}\n') + + with httpx.Client(transport=httpx.MockTransport(handle)) as http_client: + monkeypatch.setattr(plugin_client_module, "_httpx_client", http_client) + client = BasePluginClient(plugin_unique_identifier=IDENTIFIER) + path = "plugin/tenant-a/dispatch/tool/invoke" + headers = {"X-Plugin-ID": PLUGIN_ID, "Content-Type": "application/json"} + data = {"data": {"credentials": {"api_key": "secret"}}} + if stream: + assert len(list(client._stream_request("POST", path, headers=headers, data=data))) == 1 + else: + assert client._request("POST", path, headers=headers, data=data).status_code == 200 + + assert len(requests) == 1 + assert requests[0].headers["X-Plugin-ID"] == PLUGIN_ID + assert requests[0].headers[PLUGIN_IDENTIFIER_HEADER] == IDENTIFIER + assert json.loads(requests[0].content)["data"]["credentials"] == {"api_key": "secret"} + lookup.assert_not_called() + invalidate.assert_not_called() + + +def test_unbound_dispatch_keeps_legacy_headers_without_refetch(monkeypatch: pytest.MonkeyPatch) -> None: + lookup = Mock(side_effect=AssertionError("Legacy calls must let the daemon resolve installation")) + monkeypatch.setattr(PluginService, "list_installations_from_ids", lookup) + headers = {"X-Plugin-ID": PLUGIN_ID} + + prepare_dispatch_identity("plugin/tenant-a/dispatch/oauth/get_credentials", headers, None) + + lookup.assert_not_called() + assert headers == {"X-Plugin-ID": PLUGIN_ID} + + +def test_dispatch_rejects_cross_plugin_identity() -> None: + with pytest.raises(ValueError, match="does not match"): + prepare_dispatch_identity( + "plugin/tenant-a/dispatch/tool/invoke", {"X-Plugin-ID": "langgenius/other"}, IDENTIFIER + ) diff --git a/dify-agent/docs/dify-agent/user-manual/plugin-tool-layer/index.md b/dify-agent/docs/dify-agent/user-manual/plugin-tool-layer/index.md index ecd604a6619bc6..d676d2540b68fe 100644 --- a/dify-agent/docs/dify-agent/user-manual/plugin-tool-layer/index.md +++ b/dify-agent/docs/dify-agent/user-manual/plugin-tool-layer/index.md @@ -7,3 +7,9 @@ runtime values and model-facing schema, plus `execution_context` and optional observation mapping preserve the API-prepared contract. Sandbox paths use the referenced live shell session to upload files; file mappings resolve signed URLs through the borrowed Dify API client. Config and credentials are never snapshotted. + +Each prepared tool includes `plugin_unique_identifier` (version and checksum) +alongside `plugin_id`. The daemon receives it as `X-Plugin-Unique-Identifier` +and executes that exact plugin package, even if the installed version changes +during the run. Tool clients are shared only within the same complete identifier. +Older configurations can omit the identifier and retain plugin-ID dispatch. diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py index c42d0b4ae32c74..2fc833099d6ecc 100644 --- a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py @@ -86,6 +86,7 @@ async def main() -> None: # tools=[ # DifyPluginToolConfig( # plugin_id="langgenius/search", + # plugin_unique_identifier="langgenius/search:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", # provider="search", # tool_name="web_search", # credential_type="api-key", diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py index 76f4d5ac4416b8..b2dd22a3b13064 100644 --- a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py @@ -79,6 +79,7 @@ def main() -> None: # tools=[ # DifyPluginToolConfig( # plugin_id="langgenius/search", + # plugin_unique_identifier="langgenius/search:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", # provider="search", # tool_name="web_search", # credential_type="api-key", diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/configs.py b/dify-agent/src/dify_agent/layers/dify_plugin/configs.py index 8c81467ad077fb..6c7060a21fa5e1 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/configs.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/configs.py @@ -142,9 +142,13 @@ class DifyPluginToolConfig(BaseModel): declaration artifacts. They let the agent runtime validate hidden/default inputs and expose the correct LLM-facing schema without re-fetching or re-merging daemon declarations at run time. + + ``plugin_unique_identifier`` pins newly prepared tools to their package. + ``None`` preserves plugin-ID dispatch for configurations from older APIs. """ plugin_id: str + plugin_unique_identifier: str | None = Field(default=None, min_length=1) provider: str tool_name: str credential_type: DifyPluginToolCredentialType diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py b/dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py index db65265464dd28..fc5685b05d29ab 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py @@ -5,13 +5,13 @@ this module only keeps the invoke-time boundary: - POST ``/plugin/{tenant_id}/dispatch/tool/invoke`` -- request headers ``X-Api-Key``, ``X-Plugin-ID``, and ``Content-Type`` +- request headers ``X-Api-Key``, ``X-Plugin-ID``, ``X-Plugin-Unique-Identifier``, and ``Content-Type`` - top-level ``user_id`` forwarding when shared execution context includes one - stream decoding and blob-chunk merging for agent observations The shared execution-context layer still owns tenant/user daemon context, while -each tool's own ``plugin_id`` determines the transport identity placed in -``X-Plugin-ID``. +each tool's own ``plugin_id`` and ``plugin_unique_identifier`` bind the transport +to the package whose declaration and credentials were prepared by the API. """ from __future__ import annotations @@ -150,8 +150,8 @@ class DifyPluginDaemonToolClient: Callers provide business-level provider/tool/credential data per invocation, while this client supplies daemon transport identity from shared runtime - context: tenant path segment, daemon API key, plugin-specific ``X-Plugin-ID`` - header, and optional top-level ``user_id``. + context: tenant path segment, daemon API key, plugin ID and complete package + identifier headers, and optional top-level ``user_id``. """ plugin_daemon_url: str @@ -160,8 +160,14 @@ class DifyPluginDaemonToolClient: plugin_id: str user_id: str | None http_client: httpx.AsyncClient = field(repr=False) + plugin_unique_identifier: str | None = None def __post_init__(self) -> None: + if ( + self.plugin_unique_identifier is not None + and self.plugin_unique_identifier.partition(":")[0] != self.plugin_id + ): + raise ValueError("Plugin unique identifier does not match plugin_id") self.plugin_daemon_url = self.plugin_daemon_url.rstrip("/") async def invoke( @@ -253,11 +259,14 @@ async def _iter_stream_response[T: BaseModel]( def _headers(self) -> dict[str, str]: """Build required plugin-daemon transport headers for tool invocation.""" - return { + headers = { "X-Api-Key": self.plugin_daemon_api_key, "X-Plugin-ID": self.plugin_id, "Content-Type": "application/json", } + if self.plugin_unique_identifier is not None: + headers["X-Plugin-Unique-Identifier"] = self.plugin_unique_identifier + return headers def merge_blob_chunks( diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py b/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py index f48cc94811424b..3d27ffbde8f234 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py @@ -168,7 +168,7 @@ async def get_tools(self, ctx: RunContext[Deps]): services = ctx.deps.services if services.dify_api_http_client.is_closed: raise RuntimeError("Plugin tools require an open shared Dify API HTTP client.") - tool_clients: dict[str, DifyPluginDaemonToolClient] = {} + tool_clients: dict[tuple[str, str | None], DifyPluginDaemonToolClient] = {} file_context = _PluginToolFileContext( file_client=_DifyPluginToolFileClient( base_url=services.inner_api_url, @@ -182,15 +182,17 @@ async def get_tools(self, ctx: RunContext[Deps]): shell=config.shell, ) for tool_config in config.tools: - client = tool_clients.get(tool_config.plugin_id) + plugin_identity = (tool_config.plugin_id, tool_config.plugin_unique_identifier) + client = tool_clients.get(plugin_identity) if client is None: client = ExecutionContextCapability.create_tool_client( ctx.deps, config.execution_context, plugin_id=tool_config.plugin_id, + plugin_unique_identifier=tool_config.plugin_unique_identifier, http_client=services.plugin_daemon_http_client, ) - tool_clients[tool_config.plugin_id] = client + tool_clients[plugin_identity] = client effective_parameters = [parameter.model_copy(deep=True) for parameter in tool_config.parameters] _validate_required_hidden_parameters(tool_config, effective_parameters) self.add_tool( diff --git a/dify-agent/src/dify_agent/layers/execution_context/layer.py b/dify-agent/src/dify_agent/layers/execution_context/layer.py index 831ff1f3a89e2f..624f5cfe8aa82d 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/layer.py +++ b/dify-agent/src/dify_agent/layers/execution_context/layer.py @@ -24,7 +24,12 @@ def __init__(self, name: str): @staticmethod def create_tool_client( - deps: Deps, name: str, *, plugin_id: str, http_client: httpx.AsyncClient + deps: Deps, + name: str, + *, + plugin_id: str, + http_client: httpx.AsyncClient, + plugin_unique_identifier: str | None = None, ) -> DifyPluginDaemonToolClient: if http_client.is_closed: raise RuntimeError("Plugin tool invocation requires an open shared HTTP client.") @@ -32,6 +37,7 @@ def create_tool_client( return DifyPluginDaemonToolClient( tenant_id=config.tenant_id, plugin_id=plugin_id, + plugin_unique_identifier=plugin_unique_identifier, plugin_daemon_url=deps.services.plugin_daemon_url, plugin_daemon_api_key=deps.services.plugin_daemon_api_key, user_id=config.user_id, diff --git a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py index d10001c02b4395..d31c976c26ee7f 100644 --- a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py @@ -91,6 +91,7 @@ def test_dify_plugin_tools_layer_config_accepts_prepared_parameters_and_schema() "properties": {"query": {"type": "string", "description": "Search query"}}, "required": ["query"], }, + plugin_unique_identifier="langgenius/tools:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] ) @@ -159,6 +160,7 @@ def test_dify_plugin_tool_config_rejects_non_json_runtime_parameters() -> None: "tool_name": "web_search", "credential_type": "api-key", "runtime_parameters": {"bad": object()}, + "plugin_unique_identifier": "langgenius/tools:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } ) @@ -172,6 +174,7 @@ def test_dify_plugin_tool_config_rejects_non_json_schema_values() -> None: "tool_name": "web_search", "credential_type": "api-key", "parameters_json_schema": {"type": object()}, + "plugin_unique_identifier": "langgenius/tools:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } ) @@ -185,6 +188,7 @@ def test_dify_plugin_tool_config_rejects_strict_flag() -> None: "tool_name": "web_search", "credential_type": "api-key", "strict": True, + "plugin_unique_identifier": "langgenius/tools:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } ) @@ -192,5 +196,10 @@ def test_dify_plugin_tool_config_rejects_strict_flag() -> None: def test_dify_plugin_tool_config_requires_explicit_credential_type() -> None: with pytest.raises(ValidationError): _ = DifyPluginToolConfig.model_validate( - {"plugin_id": "langgenius/tools", "provider": "search", "tool_name": "web_search"} + { + "plugin_id": "langgenius/tools", + "provider": "search", + "tool_name": "web_search", + "plugin_unique_identifier": "langgenius/tools:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } ) diff --git a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py index 4d089ed7a07701..717e8af4cf0618 100644 --- a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py +++ b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py @@ -23,6 +23,50 @@ def identity(): ) +@pytest.mark.anyio +@pytest.mark.parametrize("legacy_first", [False, True]) +async def test_tools_with_different_package_bindings_do_not_share_a_daemon_client(legacy_first): + identifiers: list[str | None] = [f"langgenius/search:{version}.0.0@{'a' * 32}" for version in (1, 2)] + if legacy_first: + identifiers[0] = None + config = tools_layer.Config( + tools=[ + { + "plugin_id": "langgenius/search", + **({"plugin_unique_identifier": identifier} if identifier is not None else {}), + "provider": "search", + "tool_name": "search", + "name": name, + "credential_type": "unauthorized", + } + for identifier, name in zip(identifiers, ("old_search", "new_search"), strict=True) + ] + ) + seen = [] + + def handler(request): + seen.append(request.headers.get("X-Plugin-Unique-Identifier")) + return httpx.Response( + 200, + text='data: {"code":0,"message":"","data":{"type":"text","message":{"text":"ok"}}}\n', + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + deps = Deps( + layers={ + "tools": {"config": config.model_dump(mode="json"), "state": {}}, + "execution_context": {"config": identity().model_dump(mode="json"), "state": {}}, + }, + services=Services(client, client, plugin_daemon_api_key="daemon-secret"), + run_id="test", + ) + toolset = tools_layer.Toolset("tools") + await invoke_native_tool(deps, toolset, "old_search", {}) + await invoke_native_tool(deps, toolset, "new_search", {}) + + assert seen == identifiers + + @pytest.mark.anyio async def test_native_plugin_tool_prepares_schema_merges_hidden_parameters_and_decodes_stream(): schema = {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]} @@ -42,6 +86,7 @@ async def test_native_plugin_tool_prepares_schema_merges_hidden_parameters_and_d {"name": "scope", "type": "string", "form": "form", "required": True}, {"name": "region", "type": "string", "form": "form", "default": "global"}, ], + "plugin_unique_identifier": "langgenius/search:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } ] ) @@ -50,6 +95,7 @@ async def test_native_plugin_tool_prepares_schema_merges_hidden_parameters_and_d def handler(request): assert request.url.path == "/plugin/tenant/dispatch/tool/invoke" assert request.headers["X-Plugin-ID"] == "langgenius/search" + assert request.headers["X-Plugin-Unique-Identifier"] == config.tools[0].plugin_unique_identifier assert request.headers["X-Api-Key"] == "daemon-secret" seen.append(json.loads(request.content)) return httpx.Response( @@ -100,6 +146,7 @@ async def test_native_plugin_tool_converts_file_inputs(value, download): "credential_type": "unauthorized", "parameters": [{"name": "source", "type": "file", "form": "llm", "required": True}], "parameters_json_schema": {"type": "object", "properties": {"source": {}}, "required": ["source"]}, + "plugin_unique_identifier": "files:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } ] ) From 774b20290581223f58563a03bff1ae830caeea47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Tue, 20 Oct 2026 13:35:50 +0800 Subject: [PATCH 2/9] test(plugin): align dispatch fixtures with identifier contract --- .../agent_app/test_runtime_request_builder.py | 1 - .../nodes/agent_v2/test_dify_tools_builder.py | 10 +++++++--- .../agent_v2/test_runtime_request_builder.py | 1 - .../fixtures/plugin_model.py | 4 ++++ .../services/plugin/test_dispatch_identity.py | 18 ++++++++---------- 5 files changed, 19 insertions(+), 15 deletions(-) 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 fc6a2a82580cf1..f7a122427f4458 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 @@ -134,7 +134,6 @@ def build_layers(self, **kwargs): runtime_parameters={}, parameters=[], parameters_json_schema={"type": "object", "properties": {}, "required": []}, - plugin_unique_identifier="langgenius/time:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] ), 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 6b1677d0726c86..d4d9e8b6c1a134 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 @@ -1,9 +1,10 @@ from __future__ import annotations from collections.abc import Generator -from typing import Any +from typing import Any, override import pytest +from sqlalchemy.orm import Session from core.agent.entities import AgentToolEntity from core.app.entities.app_invoke_entities import InvokeFrom @@ -76,17 +77,20 @@ class FakeTool(PluginTool): def __init__(self, entity: ToolEntity, runtime: ToolRuntime) -> None: super().__init__(entity, runtime, runtime.tenant_id, "icon.svg", self.plugin_unique_identifier) - def tool_provider_type(self): + @override + def tool_provider_type(self) -> ToolProviderType: raise NotImplementedError + @override def _invoke( self, + session: Session, user_id: str, tool_parameters: dict[str, Any], conversation_id: str | None = None, app_id: str | None = None, message_id: str | None = None, - ) -> ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage]: + ) -> Generator[ToolInvokeMessage]: raise NotImplementedError 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 2fd7ab6096ba07..afaeadd5e3d661 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 @@ -123,7 +123,6 @@ def build_layers(self, *, tenant_id, app_id, user_id, tools, invoke_from): runtime_parameters={}, parameters=[], parameters_json_schema={"type": "object", "properties": {}, "required": []}, - plugin_unique_identifier="langgenius/time:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) ] ), diff --git a/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/fixtures/plugin_model.py b/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/fixtures/plugin_model.py index 6b24ddf48a1fa1..75f67cf5e85395 100644 --- a/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/fixtures/plugin_model.py +++ b/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/fixtures/plugin_model.py @@ -103,6 +103,8 @@ def get_model_schema( model_type: str, model: str, credentials: dict[str, object], + *, + plugin_unique_identifier: str, ) -> AIModelEntity | None: """ Get model schema @@ -254,5 +256,7 @@ def invoke_llm( stop: list[str] | None = None, stream: bool = True, app_id: str | None = None, + *, + plugin_unique_identifier: str, ) -> Generator[LLMResultChunk]: return MockModelClass.mocked_chat_create_stream(model=model, prompt_messages=prompt_messages, tools=tools) diff --git a/api/tests/unit_tests/services/plugin/test_dispatch_identity.py b/api/tests/unit_tests/services/plugin/test_dispatch_identity.py index f4655b97cd6183..2ad871780ad0b7 100644 --- a/api/tests/unit_tests/services/plugin/test_dispatch_identity.py +++ b/api/tests/unit_tests/services/plugin/test_dispatch_identity.py @@ -1,7 +1,7 @@ """Dispatch identity remains bound to the caller across installation changes.""" import json -from unittest.mock import Mock +from typing import Never import httpx import pytest @@ -15,14 +15,16 @@ IDENTIFIER = f"{PLUGIN_ID}:1.0.0@{'a' * 32}" +def _unexpected_configuration_access(*_args: object, **_kwargs: object) -> Never: + raise AssertionError("Dispatch must not discover installations or invalidate configuration") + + @pytest.mark.parametrize("stream", [False, True]) def test_dispatch_keeps_declared_version_without_refetch_or_invalidation( monkeypatch: pytest.MonkeyPatch, stream: bool ) -> None: - lookup = Mock(side_effect=AssertionError("Pinned calls must not resolve the current installation")) - invalidate = Mock(side_effect=AssertionError("Dispatch must not invalidate configuration")) - monkeypatch.setattr(PluginService, "list_installations_from_ids", lookup) - monkeypatch.setattr(PluginService, "invalidate_plugin_model_providers_cache", invalidate) + monkeypatch.setattr(PluginService, "list_installations_from_ids", _unexpected_configuration_access) + monkeypatch.setattr(PluginService, "invalidate_plugin_model_providers_cache", _unexpected_configuration_access) requests: list[httpx.Request] = [] def handle(request: httpx.Request) -> httpx.Response: @@ -44,18 +46,14 @@ def handle(request: httpx.Request) -> httpx.Response: assert requests[0].headers["X-Plugin-ID"] == PLUGIN_ID assert requests[0].headers[PLUGIN_IDENTIFIER_HEADER] == IDENTIFIER assert json.loads(requests[0].content)["data"]["credentials"] == {"api_key": "secret"} - lookup.assert_not_called() - invalidate.assert_not_called() def test_unbound_dispatch_keeps_legacy_headers_without_refetch(monkeypatch: pytest.MonkeyPatch) -> None: - lookup = Mock(side_effect=AssertionError("Legacy calls must let the daemon resolve installation")) - monkeypatch.setattr(PluginService, "list_installations_from_ids", lookup) + monkeypatch.setattr(PluginService, "list_installations_from_ids", _unexpected_configuration_access) headers = {"X-Plugin-ID": PLUGIN_ID} prepare_dispatch_identity("plugin/tenant-a/dispatch/oauth/get_credentials", headers, None) - lookup.assert_not_called() assert headers == {"X-Plugin-ID": PLUGIN_ID} From cd8c755528222e02f8380c34aa30b6941680b01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Tue, 20 Oct 2026 13:48:37 +0800 Subject: [PATCH 3/9] fix(plugin): reuse prepared model package bindings --- api/core/plugin/impl/model.py | 129 +++++------------- api/core/plugin/impl/model_runtime.py | 56 +++++--- api/core/plugin/impl/model_runtime_factory.py | 5 + api/services/plugin/dispatch_identity.py | 23 +++- .../app/apps/completion/test_app_runner.py | 13 +- .../unit_tests/core/helper/test_moderation.py | 1 + .../core/plugin/test_model_runtime_adapter.py | 8 ++ .../fixtures/plugin_model.py | 4 +- .../plugin/test_model_dispatch_binding.py | 100 ++++++++++++++ 9 files changed, 212 insertions(+), 127 deletions(-) create mode 100644 api/tests/unit_tests/services/plugin/test_model_dispatch_binding.py diff --git a/api/core/plugin/impl/model.py b/api/core/plugin/impl/model.py index 52cf2aaae15ce3..477260608a62e8 100644 --- a/api/core/plugin/impl/model.py +++ b/api/core/plugin/impl/model.py @@ -24,6 +24,7 @@ from graphon.model_runtime.entities.text_embedding_entities import EmbeddingResult from graphon.model_runtime.protocols.tts_runtime import TTSModelVoice from graphon.model_runtime.utils.encoders import jsonable_encoder +from services.plugin.dispatch_identity import plugin_dispatch_headers _POLLING_UNSUPPORTED_INVOKE_ERROR_TYPES = frozenset((NotImplementedError.__name__,)) _POLLING_UNSUPPORTED_ERROR_MESSAGE = "does not support polling" @@ -69,7 +70,7 @@ def get_model_schema( model: str, credentials: dict[str, Any], *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> AIModelEntity | None: """ Get model schema @@ -87,11 +88,7 @@ def get_model_schema( "credentials": credentials, }, ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: @@ -107,7 +104,7 @@ def validate_provider_credentials( provider: str, credentials: dict[str, Any], *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> bool: """ validate the credentials of the provider @@ -123,11 +120,7 @@ def validate_provider_credentials( "credentials": credentials, }, ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: @@ -148,7 +141,7 @@ def validate_model_credentials( model: str, credentials: dict[str, Any], *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> bool: """ validate the credentials of the provider @@ -166,11 +159,7 @@ def validate_model_credentials( "credentials": credentials, }, ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: @@ -196,7 +185,7 @@ def invoke_llm( stream: bool = True, app_id: str | None = None, *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> Generator[LLMResultChunk]: """ Invoke llm @@ -222,11 +211,7 @@ def invoke_llm( app_id=app_id, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) try: @@ -248,7 +233,7 @@ def start_llm_polling( stop: list[str] | None = None, json_schema: dict[str, Any] | None = None, *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> LLMPollingResult: """Start an LLM polling request for plugin-backed long-running jobs.""" try: @@ -273,11 +258,7 @@ def start_llm_polling( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) except PluginInvokeError as error: self._raise_typed_polling_unsupported_error(error) @@ -293,7 +274,7 @@ def check_llm_polling( credentials: dict[str, Any], plugin_state: dict[str, Any], *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> LLMPollingResult: """Check the latest state for a plugin-backed LLM polling job.""" try: @@ -313,11 +294,7 @@ def check_llm_polling( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) except PluginInvokeError as error: self._raise_typed_polling_unsupported_error(error) @@ -349,7 +326,7 @@ def get_llm_num_tokens( prompt_messages: list[PromptMessage], tools: list[PromptMessageTool] | None = None, *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> int: """ Get number of tokens for llm @@ -371,11 +348,7 @@ def get_llm_num_tokens( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: @@ -394,7 +367,7 @@ def invoke_text_embedding( texts: list[str], input_type: str, *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> EmbeddingResult: """ Invoke text embedding @@ -416,11 +389,7 @@ def invoke_text_embedding( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: @@ -439,7 +408,7 @@ def invoke_multimodal_embedding( documents: list[dict], input_type: str, *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> EmbeddingResult: """ Invoke file embedding @@ -461,11 +430,7 @@ def invoke_multimodal_embedding( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: @@ -483,7 +448,7 @@ def get_text_embedding_num_tokens( credentials: dict[str, Any], texts: list[str], *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> list[int]: """ Get number of tokens for text embedding @@ -504,11 +469,7 @@ def get_text_embedding_num_tokens( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: @@ -529,7 +490,7 @@ def invoke_rerank( score_threshold: float | None = None, top_n: int | None = None, *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> RerankResult: """ Invoke rerank @@ -553,11 +514,7 @@ def invoke_rerank( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: @@ -578,7 +535,7 @@ def invoke_multimodal_rerank( score_threshold: float | None = None, top_n: int | None = None, *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> RerankResult: """ Invoke multimodal rerank @@ -602,11 +559,7 @@ def invoke_multimodal_rerank( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: return resp @@ -624,7 +577,7 @@ def invoke_tts( content_text: str, voice: str, *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> Generator[TTSAudioChunk]: """ Invoke tts @@ -647,11 +600,7 @@ def invoke_tts( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) try: @@ -671,7 +620,7 @@ def get_tts_model_voices( credentials: dict[str, Any], language: str | None = None, *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> list[TTSModelVoice]: """ Get tts model voices @@ -692,11 +641,7 @@ def get_tts_model_voices( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: @@ -718,7 +663,7 @@ def invoke_speech_to_text( credentials: dict[str, Any], file: IO[bytes], *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> str: """ Invoke speech to text @@ -739,11 +684,7 @@ def invoke_speech_to_text( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: @@ -761,7 +702,7 @@ def invoke_moderation( credentials: dict[str, Any], text: str, *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> bool: """ Invoke moderation @@ -782,11 +723,7 @@ def invoke_moderation( }, ) ), - headers={ - "X-Plugin-ID": plugin_id, - "X-Plugin-Unique-Identifier": plugin_unique_identifier, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: diff --git a/api/core/plugin/impl/model_runtime.py b/api/core/plugin/impl/model_runtime.py index 3ded8562ac92d8..0fb18996dfae1d 100644 --- a/api/core/plugin/impl/model_runtime.py +++ b/api/core/plugin/impl/model_runtime.py @@ -27,12 +27,14 @@ ) from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType +from graphon.model_runtime.entities.provider_entities import ProviderEntity from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput, RerankResult from graphon.model_runtime.entities.text_embedding_entities import EmbeddingInputType, EmbeddingResult from graphon.model_runtime.model_providers.base.large_language_model import normalize_non_stream_runtime_result from graphon.model_runtime.protocols.runtime import ModelRuntime from graphon.model_runtime.protocols.tts_runtime import TTSChunk, TTSModelVoice from models.provider_ids import ModelProviderID +from services.plugin.dispatch_identity import PluginDispatchIdentity logger = logging.getLogger(__name__) @@ -107,6 +109,8 @@ class PluginModelRuntime(ModelRuntime): Provider discovery goes through ``PluginService`` so the plugin lifecycle methods and provider reads share one tenant-scoped cache owner. + Dispatch reuses package identities bound to prepared declarations rather + than discovering a new version after credentials have been resolved. """ tenant_id: str @@ -129,10 +133,23 @@ def __init__( self.user_id = user_id self.client = client self._plugin_service = plugin_service + self._provider_identifiers: dict[str, str] = {} @override def fetch_model_providers(self) -> Sequence[PluginModelProviderDeclaration]: - return self._plugin_service.fetch_plugin_model_providers(tenant_id=self.tenant_id, client=self.client) + providers = self._plugin_service.fetch_plugin_model_providers(tenant_id=self.tenant_id, client=self.client) + for provider in providers: + self.bind_provider_schema(provider) + return providers + + def bind_provider_schema(self, provider: ProviderEntity) -> None: + """Retain the package identity associated with a prepared declaration.""" + if isinstance(provider, PluginModelProviderDeclaration): + self._provider_identifiers[provider.provider] = provider.plugin_unique_identifier + + def _dispatch_identity(self, provider: str) -> PluginDispatchIdentity: + identifier = self._provider_identifiers.get(str(ModelProviderID(provider))) + return {"plugin_unique_identifier": identifier} if identifier is not None else {} @override def get_provider_icon(self, *, provider: str, icon_type: str, lang: str) -> tuple[bytes, str]: @@ -184,7 +201,7 @@ def validate_provider_credentials(self, *, provider: str, credentials: dict[str, tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, credentials=credentials, ) @@ -203,7 +220,7 @@ def validate_model_credentials( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model_type=model_type.value, model=model, @@ -257,7 +274,7 @@ def get_model_schema( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model_type=model_type.value, model=model, @@ -330,7 +347,7 @@ def invoke_llm( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -345,7 +362,7 @@ def invoke_llm( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -452,7 +469,7 @@ def get_llm_num_tokens( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model_type=model_type.value, model=model, @@ -479,7 +496,7 @@ def start_llm_polling( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -504,7 +521,7 @@ def check_llm_polling( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -528,7 +545,7 @@ def invoke_text_embedding( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -553,7 +570,7 @@ def invoke_multimodal_embedding( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -575,7 +592,7 @@ def get_text_embedding_num_tokens( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -601,7 +618,7 @@ def invoke_rerank( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -630,7 +647,7 @@ def invoke_multimodal_rerank( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -657,7 +674,7 @@ def invoke_tts( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -681,7 +698,7 @@ def get_tts_model_voices( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -704,7 +721,7 @@ def invoke_speech_to_text( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -727,7 +744,7 @@ def invoke_moderation( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, - plugin_unique_identifier=self._get_provider_schema(provider).plugin_unique_identifier, + **self._dispatch_identity(provider), provider=provider_name, model=model, credentials=credentials, @@ -755,6 +772,9 @@ def _get_schema_cache_key( # caller id, so the cache must only collapse ``None`` into tenant scope. cache_user_id = TENANT_SCOPE_SCHEMA_CACHE_USER_ID if self.user_id is None else self.user_id cache_key = f"{self.tenant_id}:{provider}:{model_type.value}:{model}:{cache_user_id}" + identifier = self._provider_identifiers.get(str(ModelProviderID(provider))) + if identifier is not None: + cache_key = f"{cache_key}:plugin:{identifier}" sorted_credentials = sorted(credentials.items()) if credentials else [] if not sorted_credentials: return cache_key diff --git a/api/core/plugin/impl/model_runtime_factory.py b/api/core/plugin/impl/model_runtime_factory.py index 32a4c7b89a7634..5016d04646a82e 100644 --- a/api/core/plugin/impl/model_runtime_factory.py +++ b/api/core/plugin/impl/model_runtime_factory.py @@ -42,6 +42,11 @@ def create_model_type_instance( if model_class is None: raise ValueError(f"Unsupported model type: {model_type}") + from core.plugin.impl.model_runtime import PluginModelRuntime + + if isinstance(runtime, PluginModelRuntime): + runtime.bind_provider_schema(provider_schema) + return model_class(provider_schema=provider_schema, model_runtime=runtime) diff --git a/api/services/plugin/dispatch_identity.py b/api/services/plugin/dispatch_identity.py index be33dc8d74ac2b..8509b5638292a5 100644 --- a/api/services/plugin/dispatch_identity.py +++ b/api/services/plugin/dispatch_identity.py @@ -1,22 +1,37 @@ """Bind daemon dispatches to the plugin declaration used by the caller.""" +from typing import NotRequired + +from typing_extensions import TypedDict + PLUGIN_IDENTIFIER_HEADER = "X-Plugin-Unique-Identifier" +class PluginDispatchIdentity(TypedDict, closed=True): + plugin_unique_identifier: NotRequired[str] + + +def plugin_dispatch_headers(plugin_id: str, identifier: str | None) -> dict[str, str]: + headers = {"X-Plugin-ID": plugin_id, "Content-Type": "application/json"} + if identifier is not None: + headers[PLUGIN_IDENTIFIER_HEADER] = identifier + return headers + + def prepare_dispatch_identity(path: str, headers: dict[str, str], identifier: str | None) -> None: parts = path.split("/") if len(parts) < 4 or parts[0] != "plugin" or parts[2] != "dispatch": return - plugin_id = headers.get("X-Plugin-ID") - if not plugin_id: - raise ValueError("X-Plugin-ID is required for plugin dispatch") - identifier = headers.get(PLUGIN_IDENTIFIER_HEADER) or identifier if identifier is None: # Legacy calls leave installation resolution to the daemon. return + plugin_id = headers.get("X-Plugin-ID") + if not plugin_id: + raise ValueError("X-Plugin-ID is required for plugin dispatch") + if identifier.partition(":")[0] != plugin_id: raise ValueError("Plugin unique identifier does not match X-Plugin-ID") headers[PLUGIN_IDENTIFIER_HEADER] = identifier diff --git a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py index c365bfff259353..a284d7f34506fe 100644 --- a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py @@ -29,7 +29,7 @@ from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration from core.plugin.impl.model import PluginModelClient from core.plugin.impl.model_runtime import PluginModelRuntime -from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime +from core.plugin.impl.model_runtime_factory import create_model_type_instance, create_plugin_model_runtime from core.provider_manager import ProviderManager from extensions.ext_database import db from extensions.ext_redis import RedisClientWrapper @@ -42,7 +42,6 @@ UserPromptMessage, ) from graphon.model_runtime.entities.model_entities import ModelType -from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from models.dataset import Dataset from models.enums import ConversationFromSource from models.model import App, AppMode, Conversation, IconType, Message @@ -114,10 +113,6 @@ def model_runtime(monkeypatch: pytest.MonkeyPatch) -> tuple[ProviderModelBundle, bundle = config.provider_model_bundle bundle.configuration.tenant_id = TENANT_ID bundle.configuration.custom_configuration.provider = CustomProviderConfiguration(credentials={"api_key": "token"}) - bundle.model_type_instance = LargeLanguageModel( - provider_schema=bundle.configuration.provider, - model_runtime=create_plugin_model_runtime(tenant_id=TENANT_ID), - ) provider_declaration = PluginModelProviderDeclaration.model_validate( { **bundle.configuration.provider.model_dump(), @@ -125,7 +120,11 @@ def model_runtime(monkeypatch: pytest.MonkeyPatch) -> tuple[ProviderModelBundle, "installation_source": None, } ) - monkeypatch.setattr(PluginModelRuntime, "fetch_model_providers", lambda _runtime: [provider_declaration]) + bundle.model_type_instance = create_model_type_instance( + runtime=create_plugin_model_runtime(tenant_id=TENANT_ID), + provider_schema=provider_declaration, + model_type=ModelType.LLM, + ) calls: list[RecordedCall] = [] events: list[str] = [] diff --git a/api/tests/unit_tests/core/helper/test_moderation.py b/api/tests/unit_tests/core/helper/test_moderation.py index d17e8d9d33d14e..063e8d5120bc26 100644 --- a/api/tests/unit_tests/core/helper/test_moderation.py +++ b/api/tests/unit_tests/core/helper/test_moderation.py @@ -79,6 +79,7 @@ def test_trusted_moderation_receives_hosted_credentials( tenant_id="tenant-1", user_id=None, plugin_id="langgenius/openai", + plugin_unique_identifier="langgenius/openai:1.0.0@checksum", provider="openai", model="omni-moderation-latest", credentials=hosting.provider_map[OPENAI_PROVIDER].credentials, diff --git a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py index 659c27f9072ec4..e26d300175e01b 100644 --- a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py +++ b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py @@ -243,6 +243,7 @@ def test_validate_provider_credentials_resolves_plugin_fields(self, monkeypatch: client, client_results, client_calls = _recording_model_client(monkeypatch) client_results["validate_provider_credentials"] = True runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) + runtime.fetch_model_providers() runtime.validate_provider_credentials( provider="langgenius/openai/openai", @@ -288,6 +289,7 @@ def test_invoke_llm_resolves_plugin_fields(self, monkeypatch: pytest.MonkeyPatch ] ) runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) + runtime.fetch_model_providers() result = runtime.invoke_llm( provider="langgenius/openai/openai", @@ -326,6 +328,7 @@ def test_invoke_llm_forwards_string_app_id_from_request_metadata(self, monkeypat client, client_results, client_calls = _recording_model_client(monkeypatch) client_results["invoke_llm"] = iter([]) runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) + runtime.fetch_model_providers() result = runtime.invoke_llm( provider="langgenius/openai/openai", @@ -383,6 +386,7 @@ def test_invoke_llm_returns_plugin_stream_directly(self, monkeypatch: pytest.Mon stream_result = iter([]) client_results["invoke_llm"] = stream_result runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) + runtime.fetch_model_providers() result = runtime.invoke_llm( provider="langgenius/openai/openai", @@ -422,6 +426,7 @@ def test_start_llm_polling_resolves_plugin_fields(self, monkeypatch: pytest.Monk ) client_results["start_llm_polling"] = polling_result runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) + runtime.fetch_model_providers() result = runtime.start_llm_polling( provider="langgenius/openai/openai", @@ -465,6 +470,7 @@ def test_check_llm_polling_resolves_plugin_fields(self, monkeypatch: pytest.Monk ) client_results["check_llm_polling"] = polling_result runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) + runtime.fetch_model_providers() result = runtime.check_llm_polling( provider="langgenius/openai/openai", @@ -513,6 +519,7 @@ def test_invoke_tts_uses_bound_runtime_user_when_runtime_is_unbound(self, monkey client, client_results, client_calls = _recording_model_client(monkeypatch) client_results["invoke_tts"] = iter([b"chunk"]) runtime = PluginModelRuntime(tenant_id="tenant", user_id=None, client=client, plugin_service=PluginService) + runtime.fetch_model_providers() result = runtime.invoke_tts( provider="langgenius/openai/openai", @@ -852,6 +859,7 @@ def setex(key: str, ttl: int, value: str) -> None: monkeypatch.setattr(model_runtime_module, "redis_client", redis) client_results["get_model_schema"] = schema runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) + runtime.fetch_model_providers() result = runtime.get_model_schema( provider="langgenius/openai/openai", diff --git a/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/fixtures/plugin_model.py b/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/fixtures/plugin_model.py index 75f67cf5e85395..c3070af4f16416 100644 --- a/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/fixtures/plugin_model.py +++ b/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/fixtures/plugin_model.py @@ -104,7 +104,7 @@ def get_model_schema( model: str, credentials: dict[str, object], *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> AIModelEntity | None: """ Get model schema @@ -257,6 +257,6 @@ def invoke_llm( stream: bool = True, app_id: str | None = None, *, - plugin_unique_identifier: str, + plugin_unique_identifier: str | None = None, ) -> Generator[LLMResultChunk]: return MockModelClass.mocked_chat_create_stream(model=model, prompt_messages=prompt_messages, tools=tools) diff --git a/api/tests/unit_tests/services/plugin/test_model_dispatch_binding.py b/api/tests/unit_tests/services/plugin/test_model_dispatch_binding.py new file mode 100644 index 00000000000000..8157230ac0fc78 --- /dev/null +++ b/api/tests/unit_tests/services/plugin/test_model_dispatch_binding.py @@ -0,0 +1,100 @@ +"""Model dispatches reuse prepared package identities without discovery I/O.""" + +import json + +import httpx +import pytest + +from core.plugin.entities.plugin import PluginInstallationSource +from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration +from core.plugin.impl import base as client_module +from core.plugin.impl.model import PluginModelClient +from core.plugin.impl.model_runtime_factory import create_model_type_instance, create_plugin_model_runtime +from core.plugin.plugin_service import PluginService +from graphon.model_runtime.entities.common_entities import I18nObject +from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMResultChunkDelta +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage +from graphon.model_runtime.entities.model_entities import ModelType +from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity + +IDENTIFIER = f"langgenius/openai:1.0.0@{'a' * 32}" + + +def _provider() -> PluginModelProviderDeclaration: + return PluginModelProviderDeclaration( + provider="langgenius/openai/openai", + provider_name="openai", + label=I18nObject(en_US="OpenAI"), + supported_model_types=[ModelType.LLM], + configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], + plugin_unique_identifier=IDENTIFIER, + installation_source=PluginInstallationSource.Marketplace, + ) + + +@pytest.mark.parametrize("binding", ["assembly", "discovery", "legacy"]) +def test_model_dispatch_does_not_discover_providers(monkeypatch: pytest.MonkeyPatch, binding: str) -> None: + provider = _provider() + runtime = create_plugin_model_runtime(tenant_id="tenant-a") + if binding == "discovery": + + def fetch(*, tenant_id: str, client: PluginModelClient | None = None) -> list[PluginModelProviderDeclaration]: + assert tenant_id == "tenant-a" + assert client is runtime.client + return [provider] + + monkeypatch.setattr(PluginService, "fetch_plugin_model_providers", fetch) + assert runtime.fetch_model_providers() == [provider] + else: + schema = ProviderEntity.model_validate(provider.model_dump()) if binding == "legacy" else provider + create_model_type_instance(runtime=runtime, provider_schema=schema, model_type=ModelType.LLM) + + def unexpected_discovery(*_args: object, **_kwargs: object) -> None: + raise AssertionError("Invocation must reuse the prepared provider identity") + + monkeypatch.setattr(PluginService, "fetch_plugin_model_providers", unexpected_discovery) + requests: list[httpx.Request] = [] + chunk = LLMResultChunk( + model="model", delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content="ok")) + ) + + def handle(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/plugin/tenant-a/dispatch/llm/invoke" + requests.append(request) + return httpx.Response(200, text="data: " + json.dumps({"code": 0, "message": "", "data": chunk.model_dump()})) + + with httpx.Client(transport=httpx.MockTransport(handle)) as client: + monkeypatch.setattr(client_module, "_httpx_client", client) + result = runtime.invoke_llm( + provider=provider.provider, + model="model", + credentials={"api_key": "secret"}, + model_parameters={}, + prompt_messages=[], + tools=None, + stop=None, + stream=True, + ) + assert list(result) == [chunk] + + assert len(requests) == 1 + assert requests[0].headers["X-Plugin-ID"] == "langgenius/openai" + expected_identifier = None if binding == "legacy" else IDENTIFIER + assert requests[0].headers.get("X-Plugin-Unique-Identifier") == expected_identifier + + +def test_model_schema_cache_is_scoped_to_prepared_package() -> None: + runtime = create_plugin_model_runtime(tenant_id="tenant-a") + provider = _provider() + runtime.bind_provider_schema(provider) + first = runtime._get_schema_cache_key( + provider=provider.provider, model_type=ModelType.LLM, model="model", credentials={} + ) + runtime.bind_provider_schema( + provider.model_copy(update={"plugin_unique_identifier": f"langgenius/openai:2.0.0@{'b' * 32}"}) + ) + second = runtime._get_schema_cache_key( + provider=provider.provider, model_type=ModelType.LLM, model="model", credentials={} + ) + assert first != second From 29172cfc56c140e9a6bc1646a543821394583f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Tue, 20 Oct 2026 15:02:16 +0800 Subject: [PATCH 4/9] fix(plugin): bind remaining OAuth and credential dispatches --- .../datasets/rag_pipeline/datasource_auth.py | 25 +++- .../console/workspace/tool_providers.py | 21 ++- .../console/workspace/trigger_providers.py | 17 ++- api/core/plugin/impl/datasource.py | 15 ++- api/core/plugin/impl/dynamic_select.py | 8 +- api/core/plugin/impl/oauth.py | 22 ++-- api/core/tools/tool_manager.py | 15 ++- .../data_source/credential_adapters.py | 34 +++-- api/services/data_source/provider_service.py | 67 +++++++++- api/services/plugin/dispatch_identity.py | 12 +- api/services/plugin/oauth_service.py | 10 ++ .../plugin/plugin_parameter_service.py | 42 ++++-- .../tools/builtin_tools_manage_service.py | 29 +++-- .../trigger/trigger_provider_service.py | 67 +++++----- .../rag_pipeline/test_datasource_auth.py | 35 ++++- .../console/workspace/test_tool_providers.py | 21 ++- .../core/tools/test_tool_manager.py | 7 +- .../data_source/test_credential_gateway.py | 18 ++- .../plugin/test_oauth_dispatch_identity.py | 121 ++++++++++++++++++ .../plugin/test_plugin_parameter_service.py | 37 ++++-- .../test_datasource_provider_service.py | 25 +++- .../services/test_trigger_provider_service.py | 63 ++++++--- .../test_builtin_tools_manage_service.py | 109 ++++++++++++++-- 23 files changed, 660 insertions(+), 160 deletions(-) create mode 100644 api/tests/unit_tests/services/plugin/test_oauth_dispatch_identity.py diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py index d85277ecd62ad9..5bc592a4b20b46 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py @@ -180,7 +180,7 @@ def get(self, current_tenant_id: str, current_user: Account, provider_id: str): tenant_id=tenant_id, datasource_provider_id=datasource_provider_id, ) - if not oauth_config: + if not oauth_config or not oauth_config.credentials: raise ValueError(f"No OAuth Client Config for {provider_id}") # Visibility is chosen by the user in the frontend before the redirect, @@ -203,7 +203,10 @@ def get(self, current_tenant_id: str, current_user: Account, provider_id: str): plugin_id=plugin_id, provider=provider_name, credential_id=credential_id, - extra_data={"visibility": requested_visibility.value}, + extra_data={ + "visibility": requested_visibility.value, + "plugin_unique_identifier": oauth_config.plugin_unique_identifier, + }, ) oauth_handler = OAuthHandler() redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/datasource/callback" @@ -213,7 +216,8 @@ def get(self, current_tenant_id: str, current_user: Account, provider_id: str): plugin_id=plugin_id, provider=provider_name, redirect_uri=redirect_uri, - system_credentials=oauth_config, + system_credentials=dict(oauth_config.credentials), + plugin_unique_identifier=oauth_config.plugin_unique_identifier, ) # response-contract:ignore cookie-bearing Flask response response = make_response(dump_response(PluginOAuthAuthorizationUrlResponse, authorization_url_response)) @@ -251,8 +255,18 @@ def get(self, provider_id: str): tenant_id=tenant_id, datasource_provider_id=datasource_provider_id, ) - if not oauth_client_params: + if not oauth_client_params or not oauth_client_params.credentials: raise NotFound() + try: + OAuthProxyService.validate_plugin_context( + context, + plugin_id=plugin_id, + provider=datasource_provider_id.provider_name, + plugin_unique_identifier=oauth_client_params.plugin_unique_identifier, + ) + except ValueError as error: + raise Forbidden(str(error)) from error + redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/datasource/callback" oauth_handler = OAuthHandler() oauth_response = oauth_handler.get_credentials( @@ -261,7 +275,8 @@ def get(self, provider_id: str): plugin_id=plugin_id, provider=datasource_provider_id.provider_name, redirect_uri=redirect_uri, - system_credentials=oauth_client_params, + system_credentials=dict(oauth_client_params.credentials), + plugin_unique_identifier=oauth_client_params.plugin_unique_identifier, request=request, ) credential_id: str | None = context.get("credential_id") diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index c55023ab353027..65899f6000028e 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -1156,7 +1156,10 @@ def get(self, tenant_id: str, user: Account, provider: str): tenant_id=tenant_id, plugin_id=plugin_id, provider=provider_name, - extra_data={"visibility": requested_visibility.value}, + extra_data={ + "visibility": requested_visibility.value, + "plugin_unique_identifier": oauth_client_params.plugin_unique_identifier, + }, ) redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/tool/callback" authorization_url_response = oauth_handler.get_authorization_url( @@ -1165,7 +1168,8 @@ def get(self, tenant_id: str, user: Account, provider: str): plugin_id=plugin_id, provider=provider_name, redirect_uri=redirect_uri, - system_credentials=oauth_client_params, + system_credentials=dict(oauth_client_params.credentials), + plugin_unique_identifier=oauth_client_params.plugin_unique_identifier, ) # response-contract:ignore cookie-bearing Flask response response = make_response(dump_response(PluginOAuthAuthorizationUrlResponse, authorization_url_response)) @@ -1203,6 +1207,16 @@ def get(self, provider: str): if oauth_client_params is None: raise Forbidden("no oauth available client config found for this tool provider") + try: + OAuthProxyService.validate_plugin_context( + context, + plugin_id=plugin_id, + provider=provider_name, + plugin_unique_identifier=oauth_client_params.plugin_unique_identifier, + ) + except ValueError as error: + raise Forbidden(str(error)) from error + redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/tool/callback" credentials_response = oauth_handler.get_credentials( tenant_id=tenant_id, @@ -1210,7 +1224,8 @@ def get(self, provider: str): plugin_id=plugin_id, provider=provider_name, redirect_uri=redirect_uri, - system_credentials=oauth_client_params, + system_credentials=dict(oauth_client_params.credentials), + plugin_unique_identifier=oauth_client_params.plugin_unique_identifier, request=request, ) diff --git a/api/controllers/console/workspace/trigger_providers.py b/api/controllers/console/workspace/trigger_providers.py index 5380f539ba57c0..e3887a0d336185 100644 --- a/api/controllers/console/workspace/trigger_providers.py +++ b/api/controllers/console/workspace/trigger_providers.py @@ -601,6 +601,7 @@ def get(self, tenant_id: str, user: Account, provider: str): provider=provider_name, extra_data={ "subscription_builder_id": subscription_builder.id, + "plugin_unique_identifier": oauth_client_params.plugin_unique_identifier, }, ) @@ -614,7 +615,8 @@ def get(self, tenant_id: str, user: Account, provider: str): plugin_id=plugin_id, provider=provider_name, redirect_uri=redirect_uri, - system_credentials=oauth_client_params, + system_credentials=dict(oauth_client_params.credentials), + plugin_unique_identifier=oauth_client_params.plugin_unique_identifier, ) response = make_response( @@ -672,6 +674,16 @@ def get(self, provider: str): if oauth_client_params is None: raise Forbidden("No OAuth client configuration found for this trigger provider") + try: + OAuthProxyService.validate_plugin_context( + context, + plugin_id=plugin_id, + provider=provider_name, + plugin_unique_identifier=oauth_client_params.plugin_unique_identifier, + ) + except ValueError as error: + raise Forbidden(str(error)) from error + # Get OAuth credentials from callback oauth_handler = OAuthHandler() redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/trigger/callback" @@ -682,7 +694,8 @@ def get(self, provider: str): plugin_id=plugin_id, provider=provider_name, redirect_uri=redirect_uri, - system_credentials=oauth_client_params, + system_credentials=dict(oauth_client_params.credentials), + plugin_unique_identifier=oauth_client_params.plugin_unique_identifier, request=request, ) diff --git a/api/core/plugin/impl/datasource.py b/api/core/plugin/impl/datasource.py index 5f1e9bffba6982..4afcd192e898fe 100644 --- a/api/core/plugin/impl/datasource.py +++ b/api/core/plugin/impl/datasource.py @@ -17,6 +17,7 @@ from core.plugin.impl.base import BasePluginClient from core.schemas.resolver import resolve_dify_schema_refs from models.provider_ids import DatasourceProviderID, GenericProviderID +from services.plugin.dispatch_identity import plugin_dispatch_headers from services.tools.tools_transform_service import ToolTransformService @@ -313,7 +314,14 @@ def online_drive_download_file( yield from response def validate_provider_credentials( - self, tenant_id: str, user_id: str, provider: str, plugin_id: str, credentials: dict[str, Any] + self, + tenant_id: str, + user_id: str, + provider: str, + plugin_id: str, + credentials: dict[str, Any], + *, + plugin_unique_identifier: str | None = None, ) -> bool: """ validate the credentials of the provider @@ -331,10 +339,7 @@ def validate_provider_credentials( "credentials": credentials, }, }, - headers={ - "X-Plugin-ID": plugin_id, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: diff --git a/api/core/plugin/impl/dynamic_select.py b/api/core/plugin/impl/dynamic_select.py index 0a580a2978cbd6..64d227ee17aba4 100644 --- a/api/core/plugin/impl/dynamic_select.py +++ b/api/core/plugin/impl/dynamic_select.py @@ -4,6 +4,7 @@ from core.plugin.entities.plugin_daemon import PluginDynamicSelectOptionsResponse from core.plugin.impl.base import BasePluginClient from models.provider_ids import GenericProviderID +from services.plugin.dispatch_identity import plugin_dispatch_headers class DynamicSelectClient(BasePluginClient): @@ -17,6 +18,8 @@ def fetch_dynamic_select_options( credentials: Mapping[str, Any], credential_type: str, parameter: str, + *, + plugin_unique_identifier: str | None = None, ) -> PluginDynamicSelectOptionsResponse: """ Fetch dynamic select options for a plugin parameter. @@ -35,10 +38,7 @@ def fetch_dynamic_select_options( "parameter": parameter, }, }, - headers={ - "X-Plugin-ID": plugin_id, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for options in response: diff --git a/api/core/plugin/impl/oauth.py b/api/core/plugin/impl/oauth.py index 4e53776f1b274d..d27d9a221ac720 100644 --- a/api/core/plugin/impl/oauth.py +++ b/api/core/plugin/impl/oauth.py @@ -6,6 +6,7 @@ from core.plugin.entities.plugin_daemon import PluginOAuthAuthorizationUrlResponse, PluginOAuthCredentialsResponse from core.plugin.impl.base import BasePluginClient +from services.plugin.dispatch_identity import plugin_dispatch_headers class OAuthHandler(BasePluginClient): @@ -17,6 +18,8 @@ def get_authorization_url( provider: str, redirect_uri: str, system_credentials: Mapping[str, Any], + *, + plugin_unique_identifier: str | None = None, ) -> PluginOAuthAuthorizationUrlResponse: try: response = self._request_with_plugin_daemon_response_stream( @@ -31,10 +34,7 @@ def get_authorization_url( "system_credentials": system_credentials, }, }, - headers={ - "X-Plugin-ID": plugin_id, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: return resp @@ -51,6 +51,8 @@ def get_credentials( redirect_uri: str, system_credentials: Mapping[str, Any], request: Request, + *, + plugin_unique_identifier: str | None = None, ) -> PluginOAuthCredentialsResponse: """ Get credentials from the given request. @@ -73,10 +75,7 @@ def get_credentials( "raw_http_request": binascii.hexlify(raw_request_bytes).decode(), }, }, - headers={ - "X-Plugin-ID": plugin_id, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: return resp @@ -93,6 +92,8 @@ def refresh_credentials( redirect_uri: str, system_credentials: Mapping[str, Any], credentials: Mapping[str, Any], + *, + plugin_unique_identifier: str | None = None, ) -> PluginOAuthCredentialsResponse: try: response = self._request_with_plugin_daemon_response_stream( @@ -108,10 +109,7 @@ def refresh_credentials( "credentials": credentials, }, }, - headers={ - "X-Plugin-ID": plugin_id, - "Content-Type": "application/json", - }, + headers=plugin_dispatch_headers(plugin_id, plugin_unique_identifier), ) for resp in response: return resp diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index 61935b9d231af2..ad0bedbac11324 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -309,7 +309,17 @@ def get_tool_runtime( tool_provider = ToolProviderID(provider_id) provider_name = tool_provider.provider_name redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback" - system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id) + system_credentials = BuiltinToolManageService.get_oauth_client( + tenant_id, + provider_id, + provider_controller=( + provider_controller + if isinstance(provider_controller, PluginToolProviderController) + else None + ), + ) + if system_credentials is None: + raise ToolProviderCredentialValidationError("OAuth client is not configured") oauth_handler = OAuthHandler() try: @@ -319,7 +329,8 @@ def get_tool_runtime( plugin_id=tool_provider.plugin_id, provider=provider_name, redirect_uri=redirect_uri, - system_credentials=system_credentials or {}, + system_credentials=dict(system_credentials.credentials), + plugin_unique_identifier=system_credentials.plugin_unique_identifier, credentials=decrypted_credentials, ) except Exception as exc: diff --git a/api/services/data_source/credential_adapters.py b/api/services/data_source/credential_adapters.py index d58c4ea065d128..615f0560285bc8 100644 --- a/api/services/data_source/credential_adapters.py +++ b/api/services/data_source/credential_adapters.py @@ -6,7 +6,7 @@ from configs import dify_config from core.helper import encrypter from core.helper.provider_cache import NoOpProviderCredentialCache -from core.plugin.entities.plugin_daemon import CredentialType +from core.plugin.entities.plugin_daemon import CredentialType, PluginDatasourceProviderEntity from core.plugin.impl.datasource import PluginDatasourceManager from core.plugin.impl.oauth import OAuthHandler from core.plugin.plugin_service import PluginService @@ -19,6 +19,7 @@ RefreshedDatasourceCredential, ) from services.data_source.entities.credential import DatasourceCredentialRecord, DatasourceOAuthClientConfigRecord +from services.plugin.dispatch_identity import PluginOAuthClientConfig class DatasourceOAuthClientConfigReader(Protocol): @@ -32,7 +33,7 @@ def get_oauth_client_config( class DatasourceOAuthClientResolver(Protocol): - def resolve(self, *, workspace_id: str, provider_id: DatasourceProviderID) -> dict[str, object]: ... + def resolve(self, *, workspace_id: str, provider_id: DatasourceProviderID) -> PluginOAuthClientConfig: ... class PluginDatasourceCredentialCodec: @@ -74,9 +75,16 @@ def transform_secret_fields( def datasource_secret_variables( - manager: PluginDatasourceManager, workspace_id: str, provider_id: str, credential_type: CredentialType + manager: PluginDatasourceManager, + workspace_id: str, + provider_id: str, + credential_type: CredentialType, + *, + provider_declaration: PluginDatasourceProviderEntity | None = None, ) -> tuple[str, ...]: - provider = manager.fetch_datasource_provider(tenant_id=workspace_id, provider_id=provider_id) + provider = provider_declaration or manager.fetch_datasource_provider( + tenant_id=workspace_id, provider_id=provider_id + ) if credential_type == CredentialType.API_KEY: schemas = provider.declaration.credentials_schema elif credential_type == CredentialType.OAUTH2: @@ -100,7 +108,7 @@ def __init__( self._configs = configs self._provider_manager = provider_manager - def resolve(self, *, workspace_id: str, provider_id: DatasourceProviderID) -> dict[str, object]: + def resolve(self, *, workspace_id: str, provider_id: DatasourceProviderID) -> PluginOAuthClientConfig: config = self._configs.get_oauth_client_config( workspace_id=workspace_id, provider=provider_id.provider_name, @@ -117,8 +125,9 @@ def resolve_datasource_oauth_client( workspace_id: str, provider_id: DatasourceProviderID, provider_manager: PluginDatasourceManager, -) -> dict[str, object]: - provider = provider_manager.fetch_datasource_provider( + provider_declaration: PluginDatasourceProviderEntity | None = None, +) -> PluginOAuthClientConfig: + provider = provider_declaration or provider_manager.fetch_datasource_provider( tenant_id=workspace_id, provider_id=str(provider_id), ) @@ -131,12 +140,14 @@ def resolve_datasource_oauth_client( config=[item.to_basic_provider_config() for item in oauth_schema.client_schema], cache=NoOpProviderCredentialCache(), ) - return dict(credential_encrypter.decrypt(dict(config.encrypted_tenant_params))) + return PluginOAuthClientConfig( + provider.plugin_unique_identifier, dict(credential_encrypter.decrypt(dict(config.encrypted_tenant_params))) + ) if config.system_credentials is not None and PluginService.is_plugin_verified( workspace_id, provider.plugin_unique_identifier ): - return dict(config.system_credentials) + return PluginOAuthClientConfig(provider.plugin_unique_identifier, dict(config.system_credentials)) raise DatasourceCredentialError(f"OAuth client is not configured for {provider_id}") @@ -182,7 +193,7 @@ def refresh_datasource_credential( workspace_id: str, actor_id: str, provider_id: DatasourceProviderID, - system_credentials: Mapping[str, object], + system_credentials: PluginOAuthClientConfig, credentials: Mapping[str, object], ) -> RefreshedDatasourceCredential: refreshed = handler.refresh_credentials( @@ -191,7 +202,8 @@ def refresh_datasource_credential( plugin_id=provider_id.plugin_id, provider=provider_id.provider_name, redirect_uri=f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/datasource/callback", - system_credentials=dict(system_credentials), + system_credentials=dict(system_credentials.credentials), + plugin_unique_identifier=system_credentials.plugin_unique_identifier, credentials=dict(credentials), ) return RefreshedDatasourceCredential(credentials=dict(refreshed.credentials), expires_at=refreshed.expires_at) diff --git a/api/services/data_source/provider_service.py b/api/services/data_source/provider_service.py index a37d8660a34e01..cabb5c62d4a517 100644 --- a/api/services/data_source/provider_service.py +++ b/api/services/data_source/provider_service.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, cast from services.credentials.query import CredentialQuery +from services.plugin.dispatch_identity import PluginOAuthClientConfig if TYPE_CHECKING: from models.account import Account @@ -17,7 +18,7 @@ from core.helper import encrypter from core.helper.name_generator import generate_incremental_name from core.helper.provider_cache import NoOpProviderCredentialCache -from core.plugin.entities.plugin_daemon import CredentialType +from core.plugin.entities.plugin_daemon import CredentialType, PluginDatasourceProviderEntity from core.plugin.impl.datasource import PluginDatasourceManager from core.plugin.impl.oauth import OAuthHandler from core.tools.utils.encryption import ProviderConfigCache, ProviderConfigEncrypter, create_provider_encrypter @@ -106,6 +107,9 @@ def _refresh_datasource_credentials( ) -> tuple[dict[str, Any], int]: datasource_provider_id = DatasourceProviderID(f"{plugin_id}/{provider}") provider_name = datasource_provider_id.provider_name + provider_declaration = self.provider_manager.fetch_datasource_provider( + tenant_id=tenant_id, provider_id=str(datasource_provider_id) + ) credential_id = getattr(datasource_provider, "id", None) credential_name = getattr(datasource_provider, "name", None) logger.info( @@ -125,15 +129,20 @@ def _refresh_datasource_credentials( datasource_provider=datasource_provider, plugin_id=plugin_id, provider=provider, + provider_declaration=provider_declaration, + ) + system_credentials = self.get_oauth_client( + tenant_id, datasource_provider_id, provider_declaration=provider_declaration ) - system_credentials = self.get_oauth_client(tenant_id, datasource_provider_id) + if system_credentials is None: + raise ValueError("OAuth client is not configured for this datasource provider") try: refreshed_credentials = refresh_datasource_credential( OAuthHandler(), workspace_id=tenant_id, actor_id=current_user.id, provider_id=datasource_provider_id, - system_credentials=system_credentials or {}, + system_credentials=system_credentials, credentials=decrypted_credentials, ) except Exception as exc: @@ -156,6 +165,7 @@ def _refresh_datasource_credentials( tenant_id=tenant_id, raw_credentials=refreshed_credentials.credentials, provider=provider, + provider_declaration=provider_declaration, plugin_id=plugin_id, datasource_provider=datasource_provider, ) @@ -192,12 +202,15 @@ def decrypt_datasource_provider_credentials( datasource_provider: DatasourceProvider | DatasourceCredentialRecord, plugin_id: str, provider: str, + *, + provider_declaration: PluginDatasourceProviderEntity | None = None, ) -> dict[str, Any]: encrypted_credentials = datasource_provider.encrypted_credentials credential_secret_variables = self.extract_secret_variables( tenant_id=tenant_id, provider_id=f"{plugin_id}/{provider}", credential_type=CredentialType.of(datasource_provider.auth_type), + provider_declaration=provider_declaration, ) return transform_secret_fields( tenant_id, encrypted_credentials, credential_secret_variables, encrypter.decrypt_token @@ -210,11 +223,14 @@ def encrypt_datasource_provider_credentials( plugin_id: str, raw_credentials: Mapping[str, Any], datasource_provider: DatasourceProvider | DatasourceCredentialRecord, + *, + provider_declaration: PluginDatasourceProviderEntity | None = None, ) -> dict[str, Any]: provider_credential_secret_variables = self.extract_secret_variables( tenant_id=tenant_id, provider_id=f"{plugin_id}/{provider}", credential_type=CredentialType.of(datasource_provider.auth_type), + provider_declaration=provider_declaration, ) return transform_secret_fields( tenant_id, raw_credentials, provider_credential_secret_variables, encrypter.encrypt_token @@ -506,7 +522,13 @@ def get_oauth_encrypter( cache=NoOpProviderCredentialCache(), ) - def get_oauth_client(self, tenant_id: str, datasource_provider_id: DatasourceProviderID) -> dict[str, Any] | None: + def get_oauth_client( + self, + tenant_id: str, + datasource_provider_id: DatasourceProviderID, + *, + provider_declaration: PluginDatasourceProviderEntity | None = None, + ) -> PluginOAuthClientConfig | None: """ get oauth client """ @@ -521,6 +543,7 @@ def get_oauth_client(self, tenant_id: str, datasource_provider_id: DatasourcePro workspace_id=tenant_id, provider_id=datasource_provider_id, provider_manager=self.provider_manager, + provider_declaration=provider_declaration, ) except DatasourceCredentialError as error: raise ValueError( @@ -713,6 +736,9 @@ def add_datasource_api_key_provider( """ provider_name = provider_id.provider_name plugin_id = provider_id.plugin_id + provider_declaration = self.provider_manager.fetch_datasource_provider( + tenant_id=tenant_id, provider_id=str(provider_id) + ) with sessionmaker(bind=db.engine).begin() as session: lock = f"datasource_provider_create_lock:{tenant_id}_{provider_id}_{CredentialType.API_KEY}" @@ -746,12 +772,16 @@ def add_datasource_api_key_provider( provider=provider_name, plugin_id=plugin_id, credentials=credentials, + plugin_unique_identifier=provider_declaration.plugin_unique_identifier, ) except Exception as e: raise ValueError(f"Failed to validate credentials: {str(e)}") from e provider_credential_secret_variables = self.extract_secret_variables( - tenant_id=tenant_id, provider_id=f"{provider_id}", credential_type=CredentialType.API_KEY + tenant_id=tenant_id, + provider_id=f"{provider_id}", + credential_type=CredentialType.API_KEY, + provider_declaration=provider_declaration, ) for key, value in credentials.items(): if key in provider_credential_secret_variables: @@ -767,7 +797,14 @@ def add_datasource_api_key_provider( ) session.add(datasource_provider) - def extract_secret_variables(self, tenant_id: str, provider_id: str, credential_type: CredentialType) -> list[str]: + def extract_secret_variables( + self, + tenant_id: str, + provider_id: str, + credential_type: CredentialType, + *, + provider_declaration: PluginDatasourceProviderEntity | None = None, + ) -> list[str]: """ Extract secret input form variables. @@ -777,7 +814,15 @@ def extract_secret_variables(self, tenant_id: str, provider_id: str, credential_ if credential_type not in (CredentialType.API_KEY, CredentialType.OAUTH2): raise ValueError(f"Invalid credential type: {credential_type}") try: - return list(datasource_secret_variables(self.provider_manager, tenant_id, provider_id, credential_type)) + return list( + datasource_secret_variables( + self.provider_manager, + tenant_id, + provider_id, + credential_type, + provider_declaration=provider_declaration, + ) + ) except DatasourceCredentialError as error: raise ValueError(str(error)) from error @@ -1026,6 +1071,11 @@ def update_datasource_credentials( update datasource credentials. """ + provider_declaration = ( + self.provider_manager.fetch_datasource_provider(tenant_id=tenant_id, provider_id=f"{plugin_id}/{provider}") + if credentials + else None + ) with sessionmaker(bind=db.engine).begin() as session: datasource_provider = session.scalar( select(DatasourceProvider) @@ -1057,10 +1107,12 @@ def update_datasource_credentials( # update credentials if credentials: + assert provider_declaration is not None secret_variables = self.extract_secret_variables( tenant_id=tenant_id, provider_id=f"{plugin_id}/{provider}", credential_type=CredentialType.of(datasource_provider.auth_type), + provider_declaration=provider_declaration, ) original_credentials = { key: value if key not in secret_variables else encrypter.decrypt_token(tenant_id, value) @@ -1078,6 +1130,7 @@ def update_datasource_credentials( provider=provider, plugin_id=plugin_id, credentials=new_credentials, + plugin_unique_identifier=provider_declaration.plugin_unique_identifier, ) except Exception as e: raise ValueError(f"Failed to validate credentials: {str(e)}") from e diff --git a/api/services/plugin/dispatch_identity.py b/api/services/plugin/dispatch_identity.py index 8509b5638292a5..071b758ed24b87 100644 --- a/api/services/plugin/dispatch_identity.py +++ b/api/services/plugin/dispatch_identity.py @@ -1,12 +1,22 @@ """Bind daemon dispatches to the plugin declaration used by the caller.""" -from typing import NotRequired +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, NotRequired from typing_extensions import TypedDict PLUGIN_IDENTIFIER_HEADER = "X-Plugin-Unique-Identifier" +@dataclass(frozen=True, slots=True) +class PluginOAuthClientConfig: + """OAuth credentials paired with the declaration used to resolve them.""" + + plugin_unique_identifier: str + credentials: Mapping[str, Any] = field(repr=False) + + class PluginDispatchIdentity(TypedDict, closed=True): plugin_unique_identifier: NotRequired[str] diff --git a/api/services/plugin/oauth_service.py b/api/services/plugin/oauth_service.py index b4f3f798bd834b..6870a82c254693 100644 --- a/api/services/plugin/oauth_service.py +++ b/api/services/plugin/oauth_service.py @@ -13,6 +13,16 @@ class OAuthProxyService(BasePluginClient): __MAX_AGE__ = 5 * 60 # 5 minutes __KEY_PREFIX__ = "oauth_proxy_context:" + @staticmethod + def validate_plugin_context( + context: Mapping[str, Any], *, plugin_id: str, provider: str, plugin_unique_identifier: str + ) -> None: + if context.get("plugin_id") != plugin_id or context.get("provider") != provider: + raise ValueError("OAuth context belongs to another provider") + expected_identifier = context.get("plugin_unique_identifier") + if expected_identifier is not None and expected_identifier != plugin_unique_identifier: + raise ValueError("Plugin changed during OAuth authorization; restart authorization") + @staticmethod def create_proxy_context( user_id: str, diff --git a/api/services/plugin/plugin_parameter_service.py b/api/services/plugin/plugin_parameter_service.py index 09d016e381ad2e..d34c65ff714859 100644 --- a/api/services/plugin/plugin_parameter_service.py +++ b/api/services/plugin/plugin_parameter_service.py @@ -11,6 +11,7 @@ from core.tools.utils.encryption import create_tool_provider_encrypter from core.trigger.entities.api_entities import TriggerProviderSubscriptionApiEntity from core.trigger.entities.entities import SubscriptionBuilder +from core.trigger.trigger_manager import TriggerManager from extensions.ext_database import db from models.provider_ids import TriggerProviderID from models.tools import BuiltinToolProvider @@ -42,9 +43,12 @@ def get_dynamic_select_options( """ credentials: Mapping[str, Any] = {} credential_type: str = CredentialType.UNAUTHORIZED.value + provider_name = provider if "/" in provider else f"{plugin_id}/{provider}" match provider_type: case "tool": - provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) + provider_controller = ToolManager.get_plugin_provider(provider_name, tenant_id) + plugin_unique_identifier = provider_controller.plugin_unique_identifier + credential_provider = provider_controller.entity.identity.name # init tool configuration encrypter, _ = create_tool_provider_encrypter( tenant_id=tenant_id, @@ -62,7 +66,7 @@ def get_dynamic_select_options( select(BuiltinToolProvider) .where( BuiltinToolProvider.tenant_id == tenant_id, - BuiltinToolProvider.provider == provider, + BuiltinToolProvider.provider == credential_provider, BuiltinToolProvider.id == credential_id, ) .limit(1) @@ -72,7 +76,7 @@ def get_dynamic_select_options( select(BuiltinToolProvider) .where( BuiltinToolProvider.tenant_id == tenant_id, - BuiltinToolProvider.provider == provider, + BuiltinToolProvider.provider == credential_provider, ) .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) .limit(1) @@ -89,26 +93,40 @@ def get_dynamic_select_options( subscription = TriggerSubscriptionBuilderService.get_subscription_builder( tenant_id=tenant_id, user_id=user_id, - provider_id=TriggerProviderID(f"{plugin_id}/{provider}"), + provider_id=TriggerProviderID(provider_name), subscription_builder_id=credential_id, ) if not subscription: - trigger_subscription = TriggerProviderService.get_subscription_by_id(tenant_id, credential_id) + trigger_subscription = TriggerProviderService.get_subscription_by_id( + tenant_id, credential_id, provider_id=TriggerProviderID(provider_name) + ) subscription = trigger_subscription.to_api_entity() if trigger_subscription else None else: - trigger_subscription = TriggerProviderService.get_subscription_by_id(tenant_id) + trigger_subscription = TriggerProviderService.get_subscription_by_id( + tenant_id, provider_id=TriggerProviderID(provider_name) + ) subscription = trigger_subscription.to_api_entity() if trigger_subscription else None if subscription is None: raise ValueError(f"Subscription {credential_id} not found") + trigger_controller = TriggerManager.get_trigger_provider(tenant_id, TriggerProviderID(provider_name)) + plugin_unique_identifier = trigger_controller.plugin_unique_identifier credentials = subscription.credentials credential_type = subscription.credential_type or CredentialType.UNAUTHORIZED return ( DynamicSelectClient() .fetch_dynamic_select_options( - tenant_id, user_id, plugin_id, provider, action, credentials, credential_type, parameter + tenant_id, + user_id, + plugin_id, + provider_name, + action, + credentials, + credential_type, + parameter, + plugin_unique_identifier=plugin_unique_identifier, ) .options ) @@ -133,11 +151,16 @@ def get_dynamic_select_options_with_credentials( """ from constants import HIDDEN_VALUE + provider_name = provider if "/" in provider else f"{plugin_id}/{provider}" # Get original subscription to replace hidden values (with tenant_id check for security) - original_subscription = TriggerProviderService.get_subscription_by_id(tenant_id, credential_id) + original_subscription = TriggerProviderService.get_subscription_by_id( + tenant_id, credential_id, provider_id=TriggerProviderID(provider_name) + ) if not original_subscription: raise ValueError(f"Subscription {credential_id} not found") + provider_controller = TriggerManager.get_trigger_provider(tenant_id, TriggerProviderID(provider_name)) + # Replace [__HIDDEN__] with original values resolved_credentials: dict[str, Any] = { key: (original_subscription.credentials.get(key) if value == HIDDEN_VALUE else value) @@ -150,11 +173,12 @@ def get_dynamic_select_options_with_credentials( tenant_id, user_id, plugin_id, - provider, + provider_name, action, resolved_credentials, original_subscription.credential_type or CredentialType.UNAUTHORIZED.value, parameter, + plugin_unique_identifier=provider_controller.plugin_unique_identifier, ) .options ) diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index 093756bd0a74e4..3dbfa747301e1a 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -26,7 +26,7 @@ from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.tool_label_manager import ToolLabelManager from core.tools.tool_manager import ToolManager -from core.tools.utils.encryption import create_provider_encrypter +from core.tools.utils.encryption import ProviderConfigCache, create_provider_encrypter from core.tools.utils.system_encryption import decrypt_system_params from extensions.ext_database import db from extensions.ext_redis import redis_client @@ -35,6 +35,7 @@ from models.provider_ids import ToolProviderID from models.tools import BuiltinToolProvider, ToolOAuthSystemClient, ToolOAuthTenantClient from services.credentials.query import CredentialQuery, ToolCredentialRecord +from services.plugin.dispatch_identity import PluginOAuthClientConfig from services.tools.tools_transform_service import ToolTransformService logger = logging.getLogger(__name__) @@ -156,6 +157,7 @@ def update_builtin_tool_provider( """ update builtin tool provider """ + cache: ProviderConfigCache | None = None with sessionmaker(bind=db.engine).begin() as session: # get if the provider exists db_provider = session.scalar( @@ -191,8 +193,6 @@ def update_builtin_tool_provider( # encrypt credentials db_provider.encrypted_credentials = json.dumps(encrypter.encrypt(new_credentials)) - cache.delete() - # update name if provided if name and name != db_provider.name: # check if the name is already used @@ -214,6 +214,8 @@ def update_builtin_tool_provider( except Exception as e: raise ValueError(str(e)) from e + if cache is not None: + cache.delete() return {"result": "success"} @staticmethod @@ -525,12 +527,15 @@ def is_oauth_custom_client_enabled(tenant_id: str, provider: str) -> bool: return user_client is not None and user_client.enabled @staticmethod - def get_oauth_client(tenant_id: str, provider: str) -> Mapping[str, Any] | None: + def get_oauth_client( + tenant_id: str, provider: str, *, provider_controller: PluginToolProviderController | None = None + ) -> PluginOAuthClientConfig | None: """ get builtin tool provider """ tool_provider = ToolProviderID(provider) - provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) + if provider_controller is None: + provider_controller = ToolManager.get_plugin_provider(provider, tenant_id) encrypter, _ = create_provider_encrypter( tenant_id=tenant_id, config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()], @@ -550,14 +555,12 @@ def get_oauth_client(tenant_id: str, provider: str) -> Mapping[str, Any] | None: oauth_params: Mapping[str, Any] | None = None if user_client: oauth_params = encrypter.decrypt(user_client.oauth_params) - return oauth_params + return PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, dict(oauth_params)) # only verified provider can use official oauth client - is_verified = not isinstance( - provider_controller, PluginToolProviderController - ) or PluginService.is_plugin_verified(tenant_id, provider_controller.plugin_unique_identifier) + is_verified = PluginService.is_plugin_verified(tenant_id, provider_controller.plugin_unique_identifier) if not is_verified: - return oauth_params + return None system_client = session.scalar( select(ToolOAuthSystemClient) @@ -573,7 +576,11 @@ def get_oauth_client(tenant_id: str, provider: str) -> Mapping[str, Any] | None: except Exception as e: raise ValueError(f"Error decrypting system oauth params: {e}") from e - return oauth_params + return ( + PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, dict(oauth_params)) + if oauth_params is not None + else None + ) @staticmethod def get_builtin_tool_provider_icon(provider: str): diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py index b98035fb16dcc8..1fb9fba47d6f0e 100644 --- a/api/services/trigger/trigger_provider_service.py +++ b/api/services/trigger/trigger_provider_service.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, TypedDict from services.credentials.query import CredentialQuery +from services.plugin.dispatch_identity import PluginOAuthClientConfig if TYPE_CHECKING: from models.account import Account @@ -321,33 +322,25 @@ def update_trigger_subscription( if expires_at is not None: subscription.expires_at = expires_at - # Clear subscription cache - delete_cache_for_subscription( - tenant_id=tenant_id, - provider_id=subscription.provider_id, - subscription_id=subscription.id, - ) + # Invalidate after commit so a cache miss observes committed credentials. + delete_cache_for_subscription( + tenant_id=tenant_id, provider_id=subscription.provider_id, subscription_id=subscription.id + ) @classmethod - def get_subscription_by_id(cls, tenant_id: str, subscription_id: str | None = None) -> TriggerSubscription | None: + def get_subscription_by_id( + cls, tenant_id: str, subscription_id: str | None = None, *, provider_id: TriggerProviderID | None = None + ) -> TriggerSubscription | None: """ Get a trigger subscription by the ID. """ with Session(db.engine, expire_on_commit=False) as session: - subscription: TriggerSubscription | None = None + query = select(TriggerSubscription).where(TriggerSubscription.tenant_id == tenant_id) if subscription_id: - subscription = session.scalar( - select(TriggerSubscription) - .where( - TriggerSubscription.tenant_id == tenant_id, - TriggerSubscription.id == subscription_id, - ) - .limit(1) - ) - else: - subscription = session.scalar( - select(TriggerSubscription).where(TriggerSubscription.tenant_id == tenant_id).limit(1) - ) + query = query.where(TriggerSubscription.id == subscription_id) + if provider_id is not None: + query = query.where(TriggerSubscription.provider_id == str(provider_id)) + subscription = session.scalar(query.limit(1)) if subscription: provider_controller = TriggerManager.get_trigger_provider( tenant_id, TriggerProviderID(subscription.provider_id) @@ -467,7 +460,9 @@ def refresh_oauth_token( redirect_uri = ( f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{subscription.provider_id}/trigger/callback" ) - system_credentials = cls.get_oauth_client(tenant_id, provider_id) + system_credentials = cls.get_oauth_client(tenant_id, provider_id, provider_controller=provider_controller) + if system_credentials is None: + raise ValueError("OAuth client is not configured for this trigger provider") # Refresh token oauth_handler = OAuthHandler() @@ -477,7 +472,8 @@ def refresh_oauth_token( plugin_id=provider_id.plugin_id, provider=provider_id.provider_name, redirect_uri=redirect_uri, - system_credentials=system_credentials or {}, + system_credentials=dict(system_credentials.credentials), + plugin_unique_identifier=system_credentials.plugin_unique_identifier, credentials=current_credentials, ) @@ -577,8 +573,6 @@ def refresh_subscription( # Persist refreshed properties and expires_at subscription.properties = dict(properties_encrypter.encrypt(dict(refreshed.properties))) subscription.expires_at = int(refreshed.expires_at) - properties_cache.delete() - logger.info( "Subscription refreshed (service): tenant=%s id=%s new_expires_at=%s", tenant_id, @@ -586,10 +580,18 @@ def refresh_subscription( subscription.expires_at, ) - return {"result": "success", "expires_at": int(refreshed.expires_at)} + result = {"result": "success", "expires_at": int(refreshed.expires_at)} + properties_cache.delete() + return result @classmethod - def get_oauth_client(cls, tenant_id: str, provider_id: TriggerProviderID) -> Mapping[str, Any] | None: + def get_oauth_client( + cls, + tenant_id: str, + provider_id: TriggerProviderID, + *, + provider_controller: PluginTriggerProviderController | None = None, + ) -> PluginOAuthClientConfig | None: """ Get OAuth client configuration for a provider. First tries tenant-level OAuth, then falls back to system OAuth. @@ -598,9 +600,8 @@ def get_oauth_client(cls, tenant_id: str, provider_id: TriggerProviderID) -> Map :param provider_id: Provider identifier :return: OAuth client configuration or None """ - provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( - tenant_id=tenant_id, provider_id=provider_id - ) + if provider_controller is None: + provider_controller = TriggerManager.get_trigger_provider(tenant_id=tenant_id, provider_id=provider_id) with Session(db.engine, expire_on_commit=False) as session: tenant_client = session.scalar( select(TriggerOAuthTenantClient) @@ -621,7 +622,7 @@ def get_oauth_client(cls, tenant_id: str, provider_id: TriggerProviderID) -> Map cache=NoOpProviderCredentialCache(), ) oauth_params = encrypter.decrypt(dict(tenant_client.oauth_params)) - return oauth_params + return PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, dict(oauth_params)) is_verified = PluginService.is_plugin_verified(tenant_id, provider_controller.plugin_unique_identifier) if not is_verified: @@ -643,7 +644,11 @@ def get_oauth_client(cls, tenant_id: str, provider_id: TriggerProviderID) -> Map except Exception as e: raise ValueError(f"Error decrypting system oauth params: {e}") from e - return oauth_params + return ( + PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, dict(oauth_params)) + if oauth_params is not None + else None + ) @classmethod def is_oauth_system_client_exists(cls, tenant_id: str, provider_id: TriggerProviderID) -> bool: diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py index 2e9df08672947e..8a2461fa5ddc21 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py @@ -34,6 +34,7 @@ from models.account import Account from repositories.credentials.query_repository import CredentialQueryRepository from services.data_source.provider_service import DatasourceProviderService +from services.plugin.dispatch_identity import PluginOAuthClientConfig from services.plugin.oauth_service import OAuthProxyService from tests.unit_tests.model_factories import make_account @@ -139,7 +140,9 @@ def test_get_success(self, app: Flask): patch.object( DatasourceProviderService, "get_oauth_client", - return_value=oauth_client, + return_value=PluginOAuthClientConfig( + "langgenius/notion_datasource:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", oauth_client + ), ) as get_oauth_client, patch.object( OAuthProxyService, @@ -166,7 +169,10 @@ def test_get_success(self, app: Flask): plugin_id="langgenius/notion_datasource", provider="notion", credential_id="cred-1", - extra_data={"visibility": "only_me"}, + extra_data={ + "visibility": "only_me", + "plugin_unique_identifier": "langgenius/notion_datasource:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, ) get_authorization_url.assert_called_once() assert get_authorization_url.call_args.kwargs["tenant_id"] == "tenant-1" @@ -202,7 +208,9 @@ def test_get_without_credential_id_sets_cookie(self, app: Flask): patch.object( DatasourceProviderService, "get_oauth_client", - return_value={"client_id": "abc"}, + return_value=PluginOAuthClientConfig( + "langgenius/notion_datasource:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {"client_id": "abc"} + ), ), patch.object( OAuthProxyService, @@ -235,6 +243,8 @@ def test_callback_success_new_credential(self, app: Flask): context = { "user_id": "user-1", "tenant_id": "tenant-1", + "plugin_id": "langgenius/notion_datasource", + "provider": "notion", "credential_id": None, } @@ -248,7 +258,10 @@ def test_callback_success_new_credential(self, app: Flask): patch.object( DatasourceProviderService, "get_oauth_client", - return_value={"client_id": "abc", "client_secret": "secret"}, + return_value=PluginOAuthClientConfig( + "langgenius/notion_datasource:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + {"client_id": "abc", "client_secret": "secret"}, + ), ), patch.object( OAuthHandler, @@ -339,6 +352,8 @@ def test_callback_reauthorize_existing_credential(self, app: Flask): context = { "user_id": "user-1", "tenant_id": "tenant-1", + "plugin_id": "langgenius/notion_datasource", + "provider": "notion", "credential_id": "cred-1", } @@ -352,7 +367,9 @@ def test_callback_reauthorize_existing_credential(self, app: Flask): patch.object( DatasourceProviderService, "get_oauth_client", - return_value={"client_id": "abc"}, + return_value=PluginOAuthClientConfig( + "langgenius/notion_datasource:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {"client_id": "abc"} + ), ), patch.object( OAuthHandler, @@ -386,6 +403,8 @@ def test_callback_context_id_from_cookie(self, app: Flask): context = { "user_id": "user-1", "tenant_id": "tenant-1", + "plugin_id": "langgenius/notion_datasource", + "provider": "notion", "credential_id": None, } @@ -399,7 +418,9 @@ def test_callback_context_id_from_cookie(self, app: Flask): patch.object( DatasourceProviderService, "get_oauth_client", - return_value={"client_id": "abc"}, + return_value=PluginOAuthClientConfig( + "langgenius/notion_datasource:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {"client_id": "abc"} + ), ), patch.object( OAuthHandler, @@ -412,7 +433,7 @@ def test_callback_context_id_from_cookie(self, app: Flask): return_value=None, ), ): - response = method(api, "notion") + response = method(api, _PROVIDER_ID) assert response.status_code == 302 diff --git a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py index e2e4412f2a980b..a7f9c86776fe8c 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py @@ -25,6 +25,7 @@ from models.credential_permission import CredentialPermission from models.enums import PermissionEnum from repositories.credentials.query_repository import CredentialQueryRepository +from services.plugin.dispatch_identity import PluginOAuthClientConfig if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] @@ -819,7 +820,9 @@ def _mock_oauth_start_deps(controller_module: ModuleType, monkeypatch: pytest.Mo monkeypatch.setattr( controller_module.BuiltinToolManageService, "get_oauth_client", - MagicMock(return_value={"client_id": "abc"}), + lambda *_args, **_kwargs: PluginOAuthClientConfig( + "langgenius/github:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {"client_id": "abc"} + ), ) create_proxy_context = MagicMock(return_value="ctx-id") monkeypatch.setattr(controller_module.OAuthProxyService, "create_proxy_context", create_proxy_context) @@ -861,7 +864,10 @@ def test_tool_plugin_oauth_url_stashes_valid_visibility( ) _, kwargs = create_proxy_context.call_args - assert kwargs["extra_data"] == {"visibility": expected_stored} + assert kwargs["extra_data"] == { + "visibility": expected_stored, + "plugin_unique_identifier": "langgenius/github:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } @pytest.mark.parametrize("query_string", ["", "visibility=partial_members", "visibility=nonsense"]) @@ -886,7 +892,10 @@ def test_tool_plugin_oauth_url_unknown_visibility_falls_back_to_only_me( ) _, kwargs = create_proxy_context.call_args - assert kwargs["extra_data"] == {"visibility": "only_me"} + assert kwargs["extra_data"] == { + "visibility": "only_me", + "plugin_unique_identifier": "langgenius/github:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } def _invoke_oauth_callback( @@ -897,7 +906,7 @@ def _invoke_oauth_callback( provider: str = _OAUTH_PROVIDER_PATH, ): api = controller_module.ToolOAuthCallback() - ctx = {"user_id": "user-oauth", "tenant_id": "tenant-oauth"} + ctx = {"user_id": "user-oauth", "tenant_id": "tenant-oauth", "plugin_id": "langgenius/github", "provider": "github"} if stored_visibility is not None: ctx["visibility"] = stored_visibility with app.test_request_context( @@ -916,7 +925,9 @@ def _mock_oauth_callback_deps(controller_module: ModuleType, monkeypatch: pytest monkeypatch.setattr( controller_module.BuiltinToolManageService, "get_oauth_client", - MagicMock(return_value={"client_id": "abc"}), + lambda *_args, **_kwargs: PluginOAuthClientConfig( + "langgenius/github:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {"client_id": "abc"} + ), ) add_provider = MagicMock() monkeypatch.setattr(controller_module.BuiltinToolManageService, "add_builtin_tool_provider", add_provider) diff --git a/api/tests/unit_tests/core/tools/test_tool_manager.py b/api/tests/unit_tests/core/tools/test_tool_manager.py index 6e2737eea2d903..fa3227e69349db 100644 --- a/api/tests/unit_tests/core/tools/test_tool_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_manager.py @@ -41,6 +41,7 @@ from graphon.runtime import VariablePool from models.base import TypeBase from models.tools import ApiToolProvider, BuiltinToolProvider, WorkflowToolProvider +from services.plugin.dispatch_identity import PluginOAuthClientConfig class _CallableSessionProxy: @@ -418,7 +419,7 @@ def test_get_tool_runtime_builtin_with_credentials_decrypts_and_forks( @patch("core.plugin.impl.oauth.OAuthHandler") @patch( "services.tools.builtin_tools_manage_service.BuiltinToolManageService.get_oauth_client", - return_value={"client_id": "id"}, + return_value=PluginOAuthClientConfig("langgenius/time:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {"client_id": "id"}), ) @patch("core.tools.tool_manager.time.time", return_value=1000) @patch("core.helper.credential_utils.check_credential_policy_compliance") @@ -505,7 +506,9 @@ def test_get_tool_runtime_builtin_maps_oauth_refresh_failure_to_credential_error patch("core.tools.tool_manager.time.time", return_value=1000), patch( "services.tools.builtin_tools_manage_service.BuiltinToolManageService.get_oauth_client", - return_value={"client_id": "id"}, + return_value=PluginOAuthClientConfig( + "langgenius/time:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {"client_id": "id"} + ), ), patch("core.plugin.impl.oauth.OAuthHandler") as oauth_handler_cls, ): diff --git a/api/tests/unit_tests/services/data_source/test_credential_gateway.py b/api/tests/unit_tests/services/data_source/test_credential_gateway.py index 0bb453f4de1409..a14669f172693c 100644 --- a/api/tests/unit_tests/services/data_source/test_credential_gateway.py +++ b/api/tests/unit_tests/services/data_source/test_credential_gateway.py @@ -35,6 +35,7 @@ TrustedStoredDatasourceCredentialGateway, ) from services.data_source.entities.credential import DatasourceCredentialRecord +from services.plugin.dispatch_identity import PluginOAuthClientConfig def _record( @@ -182,12 +183,15 @@ def refresh_credentials( redirect_uri: str, system_credentials: Mapping[str, object], credentials: Mapping[str, object], + *, + plugin_unique_identifier: str | None = None, ) -> PluginOAuthCredentialsResponse: self.calls.append( { "tenant_id": tenant_id, "user_id": user_id, "plugin_id": plugin_id, + "plugin_unique_identifier": plugin_unique_identifier, "provider": provider, "redirect_uri": redirect_uri, "system_credentials": system_credentials, @@ -211,9 +215,9 @@ def decrypt(self, credentials: Mapping[str, object]) -> Mapping[str, object]: class StaticOAuthClientResolver: - def resolve(self, *, workspace_id: str, provider_id: DatasourceProviderID) -> dict[str, object]: - del workspace_id, provider_id - return {} + def resolve(self, *, workspace_id: str, provider_id: DatasourceProviderID) -> PluginOAuthClientConfig: + del workspace_id + return PluginOAuthClientConfig(f"{provider_id.plugin_id}:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {}) def _oauth_refresher( @@ -247,7 +251,7 @@ def _provider(*, with_oauth_schema: bool = True) -> PluginDatasourceProviderEnti ) return PluginDatasourceProviderEntity( provider="notion_datasource", - plugin_unique_identifier="langgenius/notion_datasource:1.0.0", + plugin_unique_identifier="langgenius/notion_datasource:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", plugin_id="langgenius/notion_datasource", declaration=DatasourceProviderEntityWithPlugin( identity=DatasourceProviderIdentity( @@ -524,6 +528,7 @@ def create_encrypter(**kwargs: object) -> tuple[RecordingCredentialEncrypter, No assert handler.calls[0]["system_credentials"] == {"client_secret": "tenant-secret"} assert handler.calls[0]["credentials"] == {"integration_secret": "old-secret"} assert handler.calls[0]["user_id"] == "actor-1" + assert handler.calls[0]["plugin_unique_identifier"] == manager.provider.plugin_unique_identifier def test_oauth_refresher_rejects_tenant_client_when_provider_has_no_oauth_schema( @@ -611,8 +616,11 @@ def is_plugin_verified(workspace_id: str, plugin_unique_identifier: str) -> bool credentials={"integration_secret": "old"}, ) assert handler.calls[0]["system_credentials"] == {"client_secret": "system-secret"} + assert handler.calls[0]["plugin_unique_identifier"] == manager.provider.plugin_unique_identifier - assert verification_calls == [("workspace-1", "langgenius/notion_datasource:1.0.0")] + assert verification_calls == [ + ("workspace-1", "langgenius/notion_datasource:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ] def test_oauth_refresher_translates_plugin_refresh_failure() -> None: diff --git a/api/tests/unit_tests/services/plugin/test_oauth_dispatch_identity.py b/api/tests/unit_tests/services/plugin/test_oauth_dispatch_identity.py new file mode 100644 index 00000000000000..e4f270dd2bfce9 --- /dev/null +++ b/api/tests/unit_tests/services/plugin/test_oauth_dispatch_identity.py @@ -0,0 +1,121 @@ +"""OAuth and credential transports preserve the prepared plugin package.""" + +import json + +import httpx +import pytest +from werkzeug import Request + +from core.plugin.impl import base as client_module +from core.plugin.impl.datasource import PluginDatasourceManager +from core.plugin.impl.dynamic_select import DynamicSelectClient +from core.plugin.impl.oauth import OAuthHandler +from services.plugin.oauth_service import OAuthProxyService + +PLUGIN_ID = "langgenius/example" +IDENTIFIER = f"{PLUGIN_ID}:1.0.0@{'a' * 32}" + + +@pytest.mark.parametrize("operation", ["authorize", "callback", "refresh", "dynamic", "validate"]) +def test_dispatch_transports_preserve_package_and_credentials(monkeypatch: pytest.MonkeyPatch, operation: str) -> None: + requests: list[httpx.Request] = [] + credentials = {"client_secret": "test-secret"} + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + assert request.method == "POST" + data: dict[str, object] + if operation == "authorize": + data = {"authorization_url": "https://example.com/authorize"} + elif operation == "dynamic": + data = {"options": []} + elif operation == "validate": + data = {"result": True} + else: + data = {"credentials": {"access_token": "test-token"}, "expires_at": -1} + return httpx.Response(200, text="data: " + json.dumps({"code": 0, "message": "", "data": data})) + + with httpx.Client(transport=httpx.MockTransport(handle)) as client: + monkeypatch.setattr(client_module, "_httpx_client", client) + handler = OAuthHandler() + if operation == "authorize": + handler.get_authorization_url( + "tenant", + "user", + PLUGIN_ID, + "example", + "https://example.com/callback", + credentials, + plugin_unique_identifier=IDENTIFIER, + ) + elif operation == "callback": + handler.get_credentials( + "tenant", + "user", + PLUGIN_ID, + "example", + "https://example.com/callback", + credentials, + Request.from_values("/callback?code=test-code"), + plugin_unique_identifier=IDENTIFIER, + ) + elif operation == "refresh": + handler.refresh_credentials( + "tenant", + "user", + PLUGIN_ID, + "example", + "https://example.com/callback", + credentials, + {"refresh_token": "test-refresh"}, + plugin_unique_identifier=IDENTIFIER, + ) + elif operation == "dynamic": + DynamicSelectClient().fetch_dynamic_select_options( + "tenant", + "user", + PLUGIN_ID, + f"{PLUGIN_ID}/example", + "fetch", + credentials, + "api-key", + "option", + plugin_unique_identifier=IDENTIFIER, + ) + else: + assert PluginDatasourceManager().validate_provider_credentials( + "tenant", "user", "example", PLUGIN_ID, credentials, plugin_unique_identifier=IDENTIFIER + ) + + assert len(requests) == 1 + assert requests[0].headers["X-Plugin-ID"] == PLUGIN_ID + assert requests[0].headers["X-Plugin-Unique-Identifier"] == IDENTIFIER + payload = json.loads(requests[0].content)["data"] + key = "credentials" if operation in ("dynamic", "validate") else "system_credentials" + assert payload[key] == credentials + + +@pytest.mark.parametrize("expected_identifier", [None, IDENTIFIER]) +def test_oauth_context_accepts_matching_provider_and_legacy_context(expected_identifier: str | None) -> None: + context = {"plugin_id": PLUGIN_ID, "provider": "example", "plugin_unique_identifier": expected_identifier} + OAuthProxyService.validate_plugin_context( + context, plugin_id=PLUGIN_ID, provider="example", plugin_unique_identifier=IDENTIFIER + ) + + +@pytest.mark.parametrize( + ("plugin_id", "provider", "identifier"), + [ + ("langgenius/other", "example", IDENTIFIER), + (PLUGIN_ID, "other", IDENTIFIER), + (PLUGIN_ID, "example", f"{PLUGIN_ID}:2.0.0@{'b' * 32}"), + ], +) +def test_oauth_context_rejects_provider_or_package_changes(plugin_id: str, provider: str, identifier: str) -> None: + with pytest.raises(ValueError, match="another provider|Plugin changed"): + OAuthProxyService.validate_plugin_context( + {"plugin_id": PLUGIN_ID, "provider": "example", "plugin_unique_identifier": IDENTIFIER}, + plugin_id=plugin_id, + provider=provider, + plugin_unique_identifier=identifier, + ) diff --git a/api/tests/unit_tests/services/plugin/test_plugin_parameter_service.py b/api/tests/unit_tests/services/plugin/test_plugin_parameter_service.py index 916217ce0b1ef1..356edb60010bee 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_parameter_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_parameter_service.py @@ -16,9 +16,24 @@ from sqlalchemy.orm import Session from core.plugin.entities.plugin_daemon import CredentialType +from core.trigger.provider import PluginTriggerProviderController +from core.trigger.trigger_manager import TriggerManager from models.engine import db +from models.provider_ids import TriggerProviderID from models.tools import BuiltinToolProvider from services.plugin.plugin_parameter_service import PluginParameterService +from tests.unit_tests.core.trigger.conftest import make_controller + + +@pytest.fixture(autouse=True) +def trigger_declaration(monkeypatch: pytest.MonkeyPatch) -> None: + def resolve(tenant_id: str, provider_id: TriggerProviderID) -> PluginTriggerProviderController: + controller = make_controller(tenant_id=tenant_id, provider_id=str(provider_id)) + controller.plugin_id = provider_id.plugin_id + controller.plugin_unique_identifier = f"{provider_id.plugin_id}:1.0.0@{'a' * 32}" + return controller + + monkeypatch.setattr(TriggerManager, "get_trigger_provider", resolve) @pytest.fixture @@ -39,14 +54,16 @@ class TestGetDynamicSelectOptionsTool: @patch("services.plugin.plugin_parameter_service.ToolManager") def test_no_credentials_needed(self, mock_tool_mgr, mock_client_cls): provider_ctrl = MagicMock() + provider_ctrl.entity.identity.name = "google" + provider_ctrl.plugin_unique_identifier = "org/plugin:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" provider_ctrl.need_credentials = False - mock_tool_mgr.get_builtin_provider.return_value = provider_ctrl + mock_tool_mgr.get_plugin_provider.return_value = provider_ctrl mock_client_cls.return_value.fetch_dynamic_select_options.return_value.options = ["opt1"] result = PluginParameterService.get_dynamic_select_options( tenant_id="t1", user_id="u1", - plugin_id="p1", + plugin_id="org/plugin", provider="google", action="search", parameter="engine", @@ -70,8 +87,10 @@ def test_fetches_credentials_with_credential_id( ) -> None: tenant_id = str(uuid4()) provider_ctrl = MagicMock() + provider_ctrl.entity.identity.name = "google" + provider_ctrl.plugin_unique_identifier = "org/plugin:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" provider_ctrl.need_credentials = True - mock_tool_mgr.get_builtin_provider.return_value = provider_ctrl + mock_tool_mgr.get_plugin_provider.return_value = provider_ctrl encrypter = MagicMock() encrypter.decrypt.return_value = {"api_key": "decrypted"} mock_encrypter_fn.return_value = (encrypter, None) @@ -91,7 +110,7 @@ def test_fetches_credentials_with_credential_id( result = PluginParameterService.get_dynamic_select_options( tenant_id=tenant_id, user_id="u1", - plugin_id="p1", + plugin_id="org/plugin", provider="google", action="search", parameter="engine", @@ -110,15 +129,17 @@ def test_raises_when_tool_provider_not_found( plugin_parameter_db: Session, ) -> None: provider_ctrl = MagicMock() + provider_ctrl.entity.identity.name = "google" + provider_ctrl.plugin_unique_identifier = "org/plugin:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" provider_ctrl.need_credentials = True - mock_tool_mgr.get_builtin_provider.return_value = provider_ctrl + mock_tool_mgr.get_plugin_provider.return_value = provider_ctrl mock_encrypter_fn.return_value = (MagicMock(), None) with pytest.raises(ValueError, match="not found"): PluginParameterService.get_dynamic_select_options( tenant_id=str(uuid4()), user_id="u1", - plugin_id="p1", + plugin_id="org/plugin", provider="google", action="search", parameter="engine", @@ -215,7 +236,7 @@ def test_replaces_hidden_values(self, mock_provider_svc, mock_client_cls): result = PluginParameterService.get_dynamic_select_options_with_credentials( tenant_id="t1", user_id="u1", - plugin_id="p1", + plugin_id="org/plugin", provider="github", action="on_push", parameter="branch", @@ -237,7 +258,7 @@ def test_raises_when_subscription_not_found(self, mock_provider_svc): PluginParameterService.get_dynamic_select_options_with_credentials( tenant_id="t1", user_id="u1", - plugin_id="p1", + plugin_id="org/plugin", provider="github", action="on_push", parameter="branch", diff --git a/api/tests/unit_tests/services/test_datasource_provider_service.py b/api/tests/unit_tests/services/test_datasource_provider_service.py index 277edf685fafa4..bdea7b37433e00 100644 --- a/api/tests/unit_tests/services/test_datasource_provider_service.py +++ b/api/tests/unit_tests/services/test_datasource_provider_service.py @@ -18,6 +18,7 @@ from repositories.data_source.credential_repository import SQLAlchemyDatasourceCredentialRepository from services.data_source import provider_service as service_module from services.data_source.provider_service import DatasourceProviderService, get_current_user +from services.plugin.dispatch_identity import PluginOAuthClientConfig # --------------------------------------------------------------------------- # Helpers @@ -347,7 +348,11 @@ def test_should_refresh_oauth_tokens_when_expired(self, service, sqlite_session, persist(sqlite_session, p) with ( patch("services.data_source.provider_service.get_current_user", return_value=mock_user), - patch.object(service, "get_oauth_client", return_value={"oc": "v"}), + patch.object( + service, + "get_oauth_client", + return_value=PluginOAuthClientConfig("org/plug:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {"oc": "v"}), + ), patch.object(service, "decrypt_datasource_provider_credentials", return_value={"tok": "plain"}), ): service.get_datasource_credentials("t1", "prov", "org/plug") @@ -365,7 +370,11 @@ def test_should_include_provider_name_when_refresh_fails(self, service, sqlite_s with ( patch("services.data_source.provider_service.get_current_user", return_value=mock_user), patch("services.data_source.provider_service.OAuthHandler") as oauth_handler, - patch.object(service, "get_oauth_client", return_value={"oc": "v"}), + patch.object( + service, + "get_oauth_client", + return_value=PluginOAuthClientConfig("org/plug:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {"oc": "v"}), + ), patch.object(service, "decrypt_datasource_provider_credentials", return_value={"tok": "plain"}), ): oauth_handler.return_value.refresh_credentials.side_effect = RuntimeError("token endpoint failed") @@ -463,7 +472,11 @@ def test_should_refresh_and_return_credentials_when_oauth_expired(self, service, persist(sqlite_session, p) with ( patch("services.data_source.provider_service.get_current_user", return_value=mock_user), - patch.object(service, "get_oauth_client", return_value={"oc": "v"}), + patch.object( + service, + "get_oauth_client", + return_value=PluginOAuthClientConfig("org/plug:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {"oc": "v"}), + ), patch.object(service, "decrypt_datasource_provider_credentials", return_value={"t": "plain"}), ): result = service.get_all_datasource_credentials_by_provider("t1", "prov", "org/plug") @@ -666,7 +679,8 @@ def test_should_use_tenant_config_when_available(self, service, sqlite_session): "services.data_source.credential_adapters.create_provider_encrypter", return_value=(self._enc, None) ): result = service.get_oauth_client("t1", make_id()) - assert result == {"k": "dec"} + assert result is not None + assert result.credentials == {"k": "dec"} def test_should_fallback_to_system_credentials_when_tenant_config_missing(self, service, sqlite_session): persist( @@ -682,7 +696,8 @@ def test_should_fallback_to_system_credentials_when_tenant_config_missing(self, patch("services.data_source.credential_adapters.PluginService.is_plugin_verified", return_value=True), ): result = service.get_oauth_client("t1", make_id()) - assert result == {"k": "sys"} + assert result is not None + assert result.credentials == {"k": "sys"} def test_should_raise_value_error_when_no_oauth_config_available(self, service, sqlite_session): """Neither tenant nor system credentials → raises ValueError.""" diff --git a/api/tests/unit_tests/services/test_trigger_provider_service.py b/api/tests/unit_tests/services/test_trigger_provider_service.py index bb0507203bf90d..f1d9891a171440 100644 --- a/api/tests/unit_tests/services/test_trigger_provider_service.py +++ b/api/tests/unit_tests/services/test_trigger_provider_service.py @@ -16,8 +16,8 @@ from uuid import uuid4 import pytest -from sqlalchemy import func, select -from sqlalchemy.engine import Engine +from sqlalchemy import event, func, select +from sqlalchemy.engine import Connection, Engine from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, sessionmaker @@ -33,6 +33,7 @@ WorkflowPluginTrigger, ) from repositories.credentials.query_repository import CredentialQueryRepository +from services.plugin.dispatch_identity import PluginOAuthClientConfig from services.trigger import trigger_provider_service as service_module from services.trigger.trigger_provider_service import TriggerProviderService @@ -119,7 +120,7 @@ def provider_controller() -> Mock: controller.get_credential_schema_config.return_value = [] controller.get_properties_schema.return_value = [] controller.get_oauth_client_schema.return_value = [] - controller.plugin_unique_identifier = "langgenius/github:0.0.1" + controller.plugin_unique_identifier = "langgenius/github:0.0.1@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" return controller @@ -351,7 +352,7 @@ def test_add_duplicate_name_rolls_back_and_database_constraint_matches_precheck( def test_update_subscription_persists_fields_and_preserves_hidden_property( - mocker, trigger_db: TriggerDatabase, provider_controller: Mock + mocker, trigger_db: TriggerDatabase, provider_controller: Mock, sqlite_engine: Engine ) -> None: subscription = trigger_db.add_subscription(properties={"project": "old-encrypted"}) _patch_lock(mocker) @@ -365,16 +366,30 @@ def test_update_subscription_persists_fields_and_preserves_hidden_property( ) clear_cache = mocker.patch.object(service_module, "delete_cache_for_subscription") - TriggerProviderService.update_trigger_subscription( - trigger_db.tenant_id, - subscription.id, - name="renamed", - properties={"project": HIDDEN_VALUE, "region": "us"}, - parameters={"event": "issues"}, - credentials={"token": "plain"}, - credential_expires_at=100, - expires_at=200, - ) + events: list[str] = [] + + def committed(_connection: Connection) -> None: + events.append("commit") + + def invalidated(*_args: object, **_kwargs: object) -> None: + events.append("invalidate") + + clear_cache.side_effect = invalidated + event.listen(sqlite_engine, "commit", committed) + try: + TriggerProviderService.update_trigger_subscription( + trigger_db.tenant_id, + subscription.id, + name="renamed", + properties={"project": HIDDEN_VALUE, "region": "us"}, + parameters={"event": "issues"}, + credentials={"token": "plain"}, + credential_expires_at=100, + expires_at=200, + ) + finally: + event.remove(sqlite_engine, "commit", committed) + assert events == ["commit", "invalidate"] persisted = trigger_db.get_subscription(subscription.id) assert persisted is not None @@ -417,6 +432,12 @@ def test_get_subscription_scopes_tenant_and_decrypts( assert TriggerProviderService.get_subscription_by_id(trigger_db.other_tenant_id, subscription.id) is None result = TriggerProviderService.get_subscription_by_id(trigger_db.tenant_id, subscription.id) assert result is not None + assert ( + TriggerProviderService.get_subscription_by_id( + trigger_db.tenant_id, subscription.id, provider_id=TriggerProviderID("langgenius/other/other") + ) + is None + ) assert result.credentials == {"token": "plain"} assert result.properties == {"project": "plain"} @@ -451,7 +472,11 @@ def test_refresh_oauth_token_persists_credentials_after_commit( _patch_provider(mocker, provider_controller) encrypter = _encrypter(decrypted={"refresh": "old"}, encrypted={"access": "new"}) mocker.patch.object(service_module, "create_provider_encrypter", return_value=(encrypter, Mock())) - mocker.patch.object(TriggerProviderService, "get_oauth_client", return_value={"client": "system"}) + mocker.patch.object( + TriggerProviderService, + "get_oauth_client", + return_value=PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, {"client": "system"}), + ) handler = Mock() handler.refresh_credentials.return_value = SimpleNamespace(credentials={"access": "plain"}, expires_at=1234) mocker.patch.object(service_module, "OAuthHandler", return_value=handler) @@ -517,7 +542,9 @@ def test_oauth_client_prefers_enabled_tenant_record( service_module, "create_provider_encrypter", return_value=(_encrypter(decrypted={"client": "tenant"}), Mock()) ) - assert TriggerProviderService.get_oauth_client(trigger_db.tenant_id, trigger_db.provider_id) == {"client": "tenant"} + assert TriggerProviderService.get_oauth_client( + trigger_db.tenant_id, trigger_db.provider_id + ) == PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, {"client": "tenant"}) def test_oauth_client_falls_back_to_verified_system_record( @@ -535,7 +562,9 @@ def test_oauth_client_falls_back_to_verified_system_record( mocker.patch.object(service_module.PluginService, "is_plugin_verified", return_value=True) mocker.patch.object(service_module, "decrypt_system_params", return_value={"client": "system"}) - assert TriggerProviderService.get_oauth_client(trigger_db.tenant_id, trigger_db.provider_id) == {"client": "system"} + assert TriggerProviderService.get_oauth_client( + trigger_db.tenant_id, trigger_db.provider_id + ) == PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, {"client": "system"}) assert TriggerProviderService.is_oauth_system_client_exists(trigger_db.tenant_id, trigger_db.provider_id) diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index 4fef00d32a448c..4983cf707e2683 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -4,11 +4,18 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import httpx import pytest -from sqlalchemy import select +from sqlalchemy import event, select +from sqlalchemy.engine import Connection from sqlalchemy.orm import Session, sessionmaker +from core.plugin.entities.oauth import OAuthSchema from core.plugin.entities.plugin_daemon import CredentialType +from core.plugin.impl import base as client_module +from core.plugin.impl.oauth import OAuthHandler +from core.tools.entities.common_entities import I18nObject +from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin, ToolProviderIdentity from models.tools import BuiltinToolProvider, ToolOAuthSystemClient, ToolOAuthTenantClient from repositories.credentials.query_repository import CredentialQueryRepository from services.tools import builtin_tools_manage_service as service_module @@ -276,11 +283,23 @@ def test_updates_persisted_credentials_and_clears_cache( MagicMock(return_value=(encrypter, cache)), ) - result = BuiltinToolManageService.update_builtin_tool_provider( - "u", "tenant-1", "google", "cred-1", credentials={"key": "value"} - ) + events: list[str] = [] + + def committed(_connection: Connection) -> None: + events.append("commit") + + engine = repository_session.get_bind() + event.listen(engine, "commit", committed) + cache.delete.side_effect = lambda: events.append("invalidate") + try: + result = BuiltinToolManageService.update_builtin_tool_provider( + "u", "tenant-1", "google", "cred-1", credentials={"key": "value"} + ) + finally: + event.remove(engine, "commit", committed) assert result == {"result": "success"} + assert events == ["commit", "invalidate"] controller.validate_credentials.assert_called_once_with("u", {"key": "value"}) cache.delete.assert_called_once() repository_session.expire_all() @@ -309,6 +328,75 @@ def test_returns_schema_dict(self, monkeypatch: pytest.MonkeyPatch) -> None: class TestGetOauthClient: + def test_system_client_binds_verified_package_and_reads_rotated_credentials( + self, monkeypatch: pytest.MonkeyPatch, repository_session: Session + ) -> None: + identifier = f"langgenius/google:1.0.0@{'a' * 32}" + controller = service_module.PluginToolProviderController( + entity=ToolProviderEntityWithPlugin( + identity=ToolProviderIdentity( + author="test", + name="google", + label=I18nObject(en_US="Google"), + description=I18nObject(en_US="Google"), + icon="icon.svg", + ), + plugin_id="langgenius/google", + credentials_schema=[], + tools=[], + oauth_schema=OAuthSchema(), + ), + tenant_id="tenant-1", + plugin_id="langgenius/google", + plugin_unique_identifier=identifier, + ) + monkeypatch.setattr(service_module.ToolManager, "get_plugin_provider", lambda *_args: controller) + monkeypatch.setattr( + service_module.PluginService, "is_plugin_verified", lambda _tenant, value: value == identifier + ) + monkeypatch.setattr(service_module, "decrypt_system_params", json.loads) + row = _persist_system_oauth_client(repository_session) + row.encrypted_oauth_params = json.dumps({"client_secret": "first-test-secret"}) + repository_session.commit() + first = BuiltinToolManageService.get_oauth_client("tenant-1", "google") + assert first is not None + assert first.plugin_unique_identifier == identifier + + row.encrypted_oauth_params = json.dumps({"client_secret": "rotated-test-secret"}) + repository_session.commit() + second = BuiltinToolManageService.get_oauth_client("tenant-1", "google") + assert second is not None + assert second.credentials == {"client_secret": "rotated-test-secret"} + + controller.plugin_unique_identifier = f"langgenius/google:2.0.0@{'b' * 32}" + assert BuiltinToolManageService.get_oauth_client("tenant-1", "google") is None + requests: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, text='data: {"code":0,"message":"","data":{"authorization_url":"https://example.com"}}' + ) + + with httpx.Client(transport=httpx.MockTransport(handle)) as client: + monkeypatch.setattr(client_module, "_httpx_client", client) + for config in (first, second): + OAuthHandler().get_authorization_url( + "tenant-1", + "user", + "langgenius/google", + "google", + "https://example.com/callback", + config.credentials, + plugin_unique_identifier=config.plugin_unique_identifier, + ) + + assert [request.headers["X-Plugin-Unique-Identifier"] for request in requests] == [identifier, identifier] + assert [json.loads(request.content)["data"]["system_credentials"] for request in requests] == [ + {"client_secret": "first-test-secret"}, + {"client_secret": "rotated-test-secret"}, + ] + def test_returns_tenant_client_params_when_exists( self, monkeypatch: pytest.MonkeyPatch, @@ -316,8 +404,9 @@ def test_returns_tenant_client_params_when_exists( ) -> None: _persist_tenant_oauth_client(repository_session) controller = MagicMock() + controller.plugin_unique_identifier = "langgenius/google:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" controller.get_oauth_client_schema.return_value = list[object]() - monkeypatch.setattr(service_module.ToolManager, "get_builtin_provider", MagicMock(return_value=controller)) + monkeypatch.setattr(service_module.ToolManager, "get_plugin_provider", MagicMock(return_value=controller)) encrypter = MagicMock() encrypter.decrypt.return_value = {"client_id": "id", "client_secret": "secret"} monkeypatch.setattr( @@ -326,7 +415,8 @@ def test_returns_tenant_client_params_when_exists( result = BuiltinToolManageService.get_oauth_client("tenant-1", "google") - assert result == {"client_id": "id", "client_secret": "secret"} + assert result is not None + assert result.credentials == {"client_id": "id", "client_secret": "secret"} encrypter.decrypt.assert_called_once_with({"encrypted": "data"}) def test_falls_back_to_system_client( @@ -336,17 +426,20 @@ def test_falls_back_to_system_client( ) -> None: _persist_system_oauth_client(repository_session) controller = MagicMock() + controller.plugin_unique_identifier = "langgenius/google:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" controller.get_oauth_client_schema.return_value = list[object]() - monkeypatch.setattr(service_module.ToolManager, "get_builtin_provider", MagicMock(return_value=controller)) + monkeypatch.setattr(service_module.ToolManager, "get_plugin_provider", MagicMock(return_value=controller)) monkeypatch.setattr( service_module, "create_provider_encrypter", MagicMock(return_value=(MagicMock(), MagicMock())) ) + monkeypatch.setattr(service_module.PluginService, "is_plugin_verified", lambda *_args: True) decrypt = MagicMock(return_value={"sys_key": "sys_val"}) monkeypatch.setattr(service_module, "decrypt_system_params", decrypt) result = BuiltinToolManageService.get_oauth_client("tenant-1", "google") - assert result == {"sys_key": "sys_val"} + assert result is not None + assert result.credentials == {"sys_key": "sys_val"} decrypt.assert_called_once_with("enc") From 5a7cc89fbd52a324e8484edbc7bdb66807903c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Tue, 20 Oct 2026 15:08:05 +0800 Subject: [PATCH 5/9] fix(plugin): retain verified OAuth package snapshots --- .../data_source/credential_adapters.py | 7 ++++--- .../tools/builtin_tools_manage_service.py | 7 ++++--- .../trigger/trigger_provider_service.py | 7 ++++--- .../test_builtin_tools_manage_service.py | 21 ++++++++++++++++--- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/api/services/data_source/credential_adapters.py b/api/services/data_source/credential_adapters.py index 615f0560285bc8..7520bd66b04019 100644 --- a/api/services/data_source/credential_adapters.py +++ b/api/services/data_source/credential_adapters.py @@ -131,6 +131,7 @@ def resolve_datasource_oauth_client( tenant_id=workspace_id, provider_id=str(provider_id), ) + plugin_unique_identifier = provider.plugin_unique_identifier if config.encrypted_tenant_params is not None: oauth_schema = provider.declaration.oauth_schema if oauth_schema is None: @@ -141,13 +142,13 @@ def resolve_datasource_oauth_client( cache=NoOpProviderCredentialCache(), ) return PluginOAuthClientConfig( - provider.plugin_unique_identifier, dict(credential_encrypter.decrypt(dict(config.encrypted_tenant_params))) + plugin_unique_identifier, dict(credential_encrypter.decrypt(dict(config.encrypted_tenant_params))) ) if config.system_credentials is not None and PluginService.is_plugin_verified( - workspace_id, provider.plugin_unique_identifier + workspace_id, plugin_unique_identifier ): - return PluginOAuthClientConfig(provider.plugin_unique_identifier, dict(config.system_credentials)) + return PluginOAuthClientConfig(plugin_unique_identifier, dict(config.system_credentials)) raise DatasourceCredentialError(f"OAuth client is not configured for {provider_id}") diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index 3dbfa747301e1a..51727d68c853ac 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -536,6 +536,7 @@ def get_oauth_client( tool_provider = ToolProviderID(provider) if provider_controller is None: provider_controller = ToolManager.get_plugin_provider(provider, tenant_id) + plugin_unique_identifier = provider_controller.plugin_unique_identifier encrypter, _ = create_provider_encrypter( tenant_id=tenant_id, config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()], @@ -555,10 +556,10 @@ def get_oauth_client( oauth_params: Mapping[str, Any] | None = None if user_client: oauth_params = encrypter.decrypt(user_client.oauth_params) - return PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, dict(oauth_params)) + return PluginOAuthClientConfig(plugin_unique_identifier, dict(oauth_params)) # only verified provider can use official oauth client - is_verified = PluginService.is_plugin_verified(tenant_id, provider_controller.plugin_unique_identifier) + is_verified = PluginService.is_plugin_verified(tenant_id, plugin_unique_identifier) if not is_verified: return None @@ -577,7 +578,7 @@ def get_oauth_client( raise ValueError(f"Error decrypting system oauth params: {e}") from e return ( - PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, dict(oauth_params)) + PluginOAuthClientConfig(plugin_unique_identifier, dict(oauth_params)) if oauth_params is not None else None ) diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py index 1fb9fba47d6f0e..3002a970bca5fc 100644 --- a/api/services/trigger/trigger_provider_service.py +++ b/api/services/trigger/trigger_provider_service.py @@ -602,6 +602,7 @@ def get_oauth_client( """ if provider_controller is None: provider_controller = TriggerManager.get_trigger_provider(tenant_id=tenant_id, provider_id=provider_id) + plugin_unique_identifier = provider_controller.plugin_unique_identifier with Session(db.engine, expire_on_commit=False) as session: tenant_client = session.scalar( select(TriggerOAuthTenantClient) @@ -622,9 +623,9 @@ def get_oauth_client( cache=NoOpProviderCredentialCache(), ) oauth_params = encrypter.decrypt(dict(tenant_client.oauth_params)) - return PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, dict(oauth_params)) + return PluginOAuthClientConfig(plugin_unique_identifier, dict(oauth_params)) - is_verified = PluginService.is_plugin_verified(tenant_id, provider_controller.plugin_unique_identifier) + is_verified = PluginService.is_plugin_verified(tenant_id, plugin_unique_identifier) if not is_verified: return None @@ -645,7 +646,7 @@ def get_oauth_client( raise ValueError(f"Error decrypting system oauth params: {e}") from e return ( - PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, dict(oauth_params)) + PluginOAuthClientConfig(plugin_unique_identifier, dict(oauth_params)) if oauth_params is not None else None ) diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index 4983cf707e2683..f057ed4d425957 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -368,7 +368,17 @@ def test_system_client_binds_verified_package_and_reads_rotated_credentials( assert second is not None assert second.credentials == {"client_secret": "rotated-test-secret"} - controller.plugin_unique_identifier = f"langgenius/google:2.0.0@{'b' * 32}" + replacement_identifier = f"langgenius/google:2.0.0@{'b' * 32}" + + def replace_during_verification(_tenant: str, value: str) -> bool: + controller.plugin_unique_identifier = replacement_identifier + return value == identifier + + monkeypatch.setattr(service_module.PluginService, "is_plugin_verified", replace_during_verification) + third = BuiltinToolManageService.get_oauth_client("tenant-1", "google") + assert third is not None + assert third.plugin_unique_identifier == identifier + assert controller.plugin_unique_identifier == replacement_identifier assert BuiltinToolManageService.get_oauth_client("tenant-1", "google") is None requests: list[httpx.Request] = [] @@ -380,7 +390,7 @@ def handle(request: httpx.Request) -> httpx.Response: with httpx.Client(transport=httpx.MockTransport(handle)) as client: monkeypatch.setattr(client_module, "_httpx_client", client) - for config in (first, second): + for config in (first, second, third): OAuthHandler().get_authorization_url( "tenant-1", "user", @@ -391,10 +401,15 @@ def handle(request: httpx.Request) -> httpx.Response: plugin_unique_identifier=config.plugin_unique_identifier, ) - assert [request.headers["X-Plugin-Unique-Identifier"] for request in requests] == [identifier, identifier] + assert [request.headers["X-Plugin-Unique-Identifier"] for request in requests] == [ + identifier, + identifier, + identifier, + ] assert [json.loads(request.content)["data"]["system_credentials"] for request in requests] == [ {"client_secret": "first-test-secret"}, {"client_secret": "rotated-test-secret"}, + {"client_secret": "rotated-test-secret"}, ] def test_returns_tenant_client_params_when_exists( From 148390590f2742e0cb6845f458debb597f09f2e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Tue, 20 Oct 2026 15:10:05 +0800 Subject: [PATCH 6/9] test(plugin): align trigger OAuth controller fixtures --- .../workspace/test_trigger_provider_apis.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_trigger_provider_apis.py b/api/tests/unit_tests/controllers/console/workspace/test_trigger_provider_apis.py index ae0f77dc9b1808..494add31044916 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_trigger_provider_apis.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_trigger_provider_apis.py @@ -39,6 +39,9 @@ from core.trigger.entities.entities import RequestLog from models.account import Account from services.credentials.query import CredentialQuery +from services.plugin.dispatch_identity import PluginOAuthClientConfig + +_OAUTH_IDENTIFIER = f"langgenius/github:1.0.0@{'a' * 64}" @pytest.fixture @@ -397,7 +400,7 @@ def test_oauth_authorize_success(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.get_oauth_client", - return_value={"a": 1}, + return_value=PluginOAuthClientConfig(_OAUTH_IDENTIFIER, {"a": 1}), ), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.create_trigger_subscription_builder", @@ -406,14 +409,17 @@ def test_oauth_authorize_success(self, app: Flask) -> None: patch( "controllers.console.workspace.trigger_providers.OAuthProxyService.create_proxy_context", return_value="ctx", - ), + ) as create_context, patch( "controllers.console.workspace.trigger_providers.OAuthHandler.get_authorization_url", return_value=MagicMock(authorization_url="url"), - ), + ) as authorize, ): resp = method(api, "t1", mock_user(), "github") assert resp.status_code == 200 + assert create_context.call_args.kwargs["extra_data"]["plugin_unique_identifier"] == _OAUTH_IDENTIFIER + assert authorize.call_args.kwargs["plugin_unique_identifier"] == _OAUTH_IDENTIFIER + assert authorize.call_args.kwargs["system_credentials"] == {"a": 1} def test_oauth_authorize_no_client(self, app: Flask) -> None: api = TriggerOAuthAuthorizeApi() @@ -445,6 +451,9 @@ def test_oauth_callback_success(self, app: Flask) -> None: "user_id": "u1", "tenant_id": "t1", "subscription_builder_id": "b1", + "plugin_id": "langgenius/github", + "provider": "github", + "plugin_unique_identifier": _OAUTH_IDENTIFIER, } with ( @@ -454,18 +463,20 @@ def test_oauth_callback_success(self, app: Flask) -> None: ), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.get_oauth_client", - return_value={"a": 1}, + return_value=PluginOAuthClientConfig(_OAUTH_IDENTIFIER, {"a": 1}), ), patch( "controllers.console.workspace.trigger_providers.OAuthHandler.get_credentials", return_value=MagicMock(credentials={"a": 1}, expires_at=1), - ), + ) as get_credentials, patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.update_trigger_subscription_builder" ) as mock_update_builder, ): resp = method(api, "github") assert resp.status_code == 302 + assert get_credentials.call_args.kwargs["plugin_unique_identifier"] == _OAUTH_IDENTIFIER + assert get_credentials.call_args.kwargs["system_credentials"] == {"a": 1} mock_update_builder.assert_called_once_with( tenant_id="t1", user_id="u1", @@ -506,6 +517,9 @@ def test_oauth_callback_empty_credentials(self, app: Flask) -> None: "user_id": "u1", "tenant_id": "t1", "subscription_builder_id": "b1", + "plugin_id": "langgenius/github", + "provider": "github", + "plugin_unique_identifier": _OAUTH_IDENTIFIER, } with ( @@ -516,7 +530,7 @@ def test_oauth_callback_empty_credentials(self, app: Flask) -> None: ), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.get_oauth_client", - return_value={"a": 1}, + return_value=PluginOAuthClientConfig(_OAUTH_IDENTIFIER, {"a": 1}), ), patch( "controllers.console.workspace.trigger_providers.OAuthHandler.get_credentials", From 11e2516d35e923ad37f4968e9bcda74e0af5a10e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Tue, 20 Oct 2026 15:19:58 +0800 Subject: [PATCH 7/9] test(plugin): reject OAuth callbacks after package replacement --- .../rag_pipeline/test_datasource_auth.py | 24 +++++++++++++ .../console/workspace/test_tool_providers.py | 27 +++++++++++++++ .../workspace/test_trigger_provider_apis.py | 34 +++++++++++++++++++ .../services/plugin/test_dispatch_identity.py | 10 ++++++ 4 files changed, 95 insertions(+) diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py index 8a2461fa5ddc21..4f4e850d29bec6 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py @@ -230,6 +230,30 @@ def test_get_without_credential_id_sets_cookie(self, app: Flask): class TestDatasourceOAuthCallback: + def test_callback_rejects_replaced_package_before_dispatch( + self, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + plugin_id = "langgenius/notion_datasource" + context = { + "user_id": "user-1", + "tenant_id": "tenant-1", + "plugin_id": plugin_id, + "provider": "notion", + "plugin_unique_identifier": f"{plugin_id}:1.0.0@{'a' * 64}", + } + config = PluginOAuthClientConfig(f"{plugin_id}:2.0.0@{'b' * 64}", {"client_secret": "test-secret"}) + monkeypatch.setattr(OAuthProxyService, "use_proxy_context", lambda _context_id: context) + monkeypatch.setattr(DatasourceProviderService, "get_oauth_client", lambda *_args, **_kwargs: config) + + def unexpected_dispatch(*_args: object, **_kwargs: object) -> None: + raise AssertionError("Changed packages must not receive OAuth credentials") + + monkeypatch.setattr(OAuthHandler, "get_credentials", unexpected_dispatch) + api = DatasourceOAuthCallback() + with app.test_request_context("/?context_id=ctx"): + with pytest.raises(Forbidden, match="Plugin changed"): + inspect.unwrap(api.get)(api, _PROVIDER_ID) + def test_callback_success_new_credential(self, app: Flask): api = DatasourceOAuthCallback() method = inspect.unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py index a7f9c86776fe8c..c7a7ee03f7792a 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py @@ -15,6 +15,7 @@ from flask import Flask from flask.views import MethodView from sqlalchemy.orm import Session, scoped_session, sessionmaker +from werkzeug.exceptions import Forbidden from core.tools.entities.api_entities import ToolProviderApiEntity as CoreToolProviderApiEntity from core.tools.entities.common_entities import I18nObject @@ -918,6 +919,32 @@ def _invoke_oauth_callback( return unwrap(api.get)(api, provider=provider) +def test_tool_oauth_callback_rejects_replaced_package_before_dispatch( + app: Flask, controller_module: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + context = { + "user_id": "user-oauth", + "tenant_id": "tenant-oauth", + "plugin_id": "langgenius/github", + "provider": "github", + "plugin_unique_identifier": f"langgenius/github:1.0.0@{'a' * 64}", + } + config = PluginOAuthClientConfig(f"langgenius/github:2.0.0@{'b' * 64}", {"client_secret": "test-secret"}) + monkeypatch.setattr(controller_module.OAuthProxyService, "use_proxy_context", lambda _context_id: context) + monkeypatch.setattr( + controller_module.BuiltinToolManageService, "get_oauth_client", lambda *_args, **_kwargs: config + ) + + def unexpected_dispatch(*_args: object, **_kwargs: object) -> None: + raise AssertionError("Changed packages must not receive OAuth credentials") + + monkeypatch.setattr(controller_module.OAuthHandler, "get_credentials", unexpected_dispatch) + api = controller_module.ToolOAuthCallback() + with app.test_request_context("/", headers={"Cookie": "context_id=ctx-id"}): + with pytest.raises(Forbidden, match="Plugin changed"): + unwrap(api.get)(api, provider=_OAUTH_PROVIDER_PATH) + + @contextmanager def _mock_oauth_callback_deps(controller_module: ModuleType, monkeypatch: pytest.MonkeyPatch): """Mock everything ToolOAuthCallback.get calls out to and capture the diff --git a/api/tests/unit_tests/controllers/console/workspace/test_trigger_provider_apis.py b/api/tests/unit_tests/controllers/console/workspace/test_trigger_provider_apis.py index 494add31044916..e6c949eb3e5820 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_trigger_provider_apis.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_trigger_provider_apis.py @@ -4,6 +4,7 @@ from collections.abc import Iterator from datetime import datetime +from importlib import import_module from inspect import unwrap from types import SimpleNamespace from unittest.mock import ANY, MagicMock, create_autospec, patch @@ -392,6 +393,39 @@ def test_update_rebuild(self, app: Flask) -> None: class TestTriggerOAuthApis: + def test_oauth_callback_rejects_replaced_package_before_dispatch( + self, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + context = { + "user_id": "u1", + "tenant_id": "t1", + "subscription_builder_id": "b1", + "plugin_id": "langgenius/github", + "provider": "github", + "plugin_unique_identifier": _OAUTH_IDENTIFIER, + } + config = PluginOAuthClientConfig(f"langgenius/github:2.0.0@{'b' * 64}", {"client_secret": "test-secret"}) + controller_module = import_module("controllers.console.workspace.trigger_providers") + monkeypatch.setattr( + controller_module.OAuthProxyService, + "use_proxy_context", + lambda _context_id: context, + ) + monkeypatch.setattr( + controller_module.TriggerProviderService, + "get_oauth_client", + lambda *_args, **_kwargs: config, + ) + + def unexpected_dispatch(*_args: object, **_kwargs: object) -> None: + raise AssertionError("Changed packages must not receive OAuth credentials") + + monkeypatch.setattr(controller_module.OAuthHandler, "get_credentials", unexpected_dispatch) + api = TriggerOAuthCallbackApi() + with app.test_request_context("/", headers={"Cookie": "context_id=ctx"}): + with pytest.raises(Forbidden, match="Plugin changed"): + unwrap(api.get)(api, "github") + def test_oauth_authorize_success(self, app: Flask) -> None: api = TriggerOAuthAuthorizeApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/services/plugin/test_dispatch_identity.py b/api/tests/unit_tests/services/plugin/test_dispatch_identity.py index 2ad871780ad0b7..1065e29e75b323 100644 --- a/api/tests/unit_tests/services/plugin/test_dispatch_identity.py +++ b/api/tests/unit_tests/services/plugin/test_dispatch_identity.py @@ -62,3 +62,13 @@ def test_dispatch_rejects_cross_plugin_identity() -> None: prepare_dispatch_identity( "plugin/tenant-a/dispatch/tool/invoke", {"X-Plugin-ID": "langgenius/other"}, IDENTIFIER ) + + +def test_pinned_dispatch_requires_plugin_id() -> None: + with pytest.raises(ValueError, match="X-Plugin-ID is required"): + prepare_dispatch_identity("plugin/tenant-a/dispatch/tool/invoke", {}, IDENTIFIER) + + +def test_explicit_empty_identifier_cannot_fall_back_to_legacy_dispatch() -> None: + with pytest.raises(ValueError, match="must not be empty"): + BasePluginClient(plugin_unique_identifier="") From 87c84ba9ce650e3d2bbeb113cf90e2b40b665aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Tue, 20 Oct 2026 17:42:57 +0800 Subject: [PATCH 8/9] fix(plugin): clarify datasource OAuth client contract --- api/services/data_source/provider_service.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/services/data_source/provider_service.py b/api/services/data_source/provider_service.py index cabb5c62d4a517..c6e14db28808e9 100644 --- a/api/services/data_source/provider_service.py +++ b/api/services/data_source/provider_service.py @@ -134,8 +134,6 @@ def _refresh_datasource_credentials( system_credentials = self.get_oauth_client( tenant_id, datasource_provider_id, provider_declaration=provider_declaration ) - if system_credentials is None: - raise ValueError("OAuth client is not configured for this datasource provider") try: refreshed_credentials = refresh_datasource_credential( OAuthHandler(), @@ -528,7 +526,7 @@ def get_oauth_client( datasource_provider_id: DatasourceProviderID, *, provider_declaration: PluginDatasourceProviderEntity | None = None, - ) -> PluginOAuthClientConfig | None: + ) -> PluginOAuthClientConfig: """ get oauth client """ From f71fd08a31eef962a68b607c3e0083896e13e33e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Tue, 20 Oct 2026 17:51:20 +0800 Subject: [PATCH 9/9] fix(plugin): preserve empty OAuth client refresh fallback --- api/core/tools/tool_manager.py | 15 +++++--- api/services/data_source/provider_service.py | 5 +-- .../trigger/trigger_provider_service.py | 11 +++--- .../core/tools/test_tool_manager.py | 34 +++++++++++++++++++ .../test_datasource_provider_service.py | 23 +++++++++++-- .../services/test_trigger_provider_service.py | 14 ++++++-- 6 files changed, 87 insertions(+), 15 deletions(-) diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index ad0bedbac11324..adcff12d6d0895 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -309,6 +309,11 @@ def get_tool_runtime( tool_provider = ToolProviderID(provider_id) provider_name = tool_provider.provider_name redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback" + plugin_unique_identifier = ( + provider_controller.plugin_unique_identifier + if isinstance(provider_controller, PluginToolProviderController) + else None + ) system_credentials = BuiltinToolManageService.get_oauth_client( tenant_id, provider_id, @@ -318,8 +323,8 @@ def get_tool_runtime( else None ), ) - if system_credentials is None: - raise ToolProviderCredentialValidationError("OAuth client is not configured") + if system_credentials is not None: + plugin_unique_identifier = system_credentials.plugin_unique_identifier oauth_handler = OAuthHandler() try: @@ -329,8 +334,10 @@ def get_tool_runtime( plugin_id=tool_provider.plugin_id, provider=provider_name, redirect_uri=redirect_uri, - system_credentials=dict(system_credentials.credentials), - plugin_unique_identifier=system_credentials.plugin_unique_identifier, + system_credentials=( + dict(system_credentials.credentials) if system_credentials is not None else {} + ), + plugin_unique_identifier=plugin_unique_identifier, credentials=decrypted_credentials, ) except Exception as exc: diff --git a/api/services/data_source/provider_service.py b/api/services/data_source/provider_service.py index c6e14db28808e9..967cbf259968d7 100644 --- a/api/services/data_source/provider_service.py +++ b/api/services/data_source/provider_service.py @@ -110,6 +110,7 @@ def _refresh_datasource_credentials( provider_declaration = self.provider_manager.fetch_datasource_provider( tenant_id=tenant_id, provider_id=str(datasource_provider_id) ) + plugin_unique_identifier = provider_declaration.plugin_unique_identifier credential_id = getattr(datasource_provider, "id", None) credential_name = getattr(datasource_provider, "name", None) logger.info( @@ -140,7 +141,7 @@ def _refresh_datasource_credentials( workspace_id=tenant_id, actor_id=current_user.id, provider_id=datasource_provider_id, - system_credentials=system_credentials, + system_credentials=system_credentials or PluginOAuthClientConfig(plugin_unique_identifier, {}), credentials=decrypted_credentials, ) except Exception as exc: @@ -526,7 +527,7 @@ def get_oauth_client( datasource_provider_id: DatasourceProviderID, *, provider_declaration: PluginDatasourceProviderEntity | None = None, - ) -> PluginOAuthClientConfig: + ) -> PluginOAuthClientConfig | None: """ get oauth client """ diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py index 3002a970bca5fc..30717345cdefc0 100644 --- a/api/services/trigger/trigger_provider_service.py +++ b/api/services/trigger/trigger_provider_service.py @@ -460,9 +460,8 @@ def refresh_oauth_token( redirect_uri = ( f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{subscription.provider_id}/trigger/callback" ) + plugin_unique_identifier = provider_controller.plugin_unique_identifier system_credentials = cls.get_oauth_client(tenant_id, provider_id, provider_controller=provider_controller) - if system_credentials is None: - raise ValueError("OAuth client is not configured for this trigger provider") # Refresh token oauth_handler = OAuthHandler() @@ -472,8 +471,12 @@ def refresh_oauth_token( plugin_id=provider_id.plugin_id, provider=provider_id.provider_name, redirect_uri=redirect_uri, - system_credentials=dict(system_credentials.credentials), - plugin_unique_identifier=system_credentials.plugin_unique_identifier, + system_credentials=dict(system_credentials.credentials) if system_credentials is not None else {}, + plugin_unique_identifier=( + system_credentials.plugin_unique_identifier + if system_credentials is not None + else plugin_unique_identifier + ), credentials=current_credentials, ) diff --git a/api/tests/unit_tests/core/tools/test_tool_manager.py b/api/tests/unit_tests/core/tools/test_tool_manager.py index fa3227e69349db..576387cdea22bd 100644 --- a/api/tests/unit_tests/core/tools/test_tool_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_manager.py @@ -30,6 +30,7 @@ ToolInvokeFrom, ToolParameter, ToolProviderEntity, + ToolProviderEntityWithPlugin, ToolProviderIdentity, ToolProviderType, ) @@ -423,6 +424,8 @@ def test_get_tool_runtime_builtin_with_credentials_decrypts_and_forks( ) @patch("core.tools.tool_manager.time.time", return_value=1000) @patch("core.helper.credential_utils.check_credential_policy_compliance") +@pytest.mark.parametrize("client_credentials", [None, {}, {"client_id": "id"}]) +@pytest.mark.parametrize("plugin_provider", [False, True]) def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials( mock_check, mock_time, @@ -431,6 +434,8 @@ def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials( mock_create_provider_encrypter, monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase, + client_credentials: dict[str, str] | None, + plugin_provider: bool, ): tool = Mock() tool.fork_tool_runtime.return_value = "runtime-tool" @@ -440,6 +445,29 @@ def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials( get_credentials_schema_by_type=Mock(return_value=[]), ) tenant_id = "00000000-0000-0000-0000-000000000001" + identifier = "langgenius/time:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + mock_get_oauth_client.return_value = ( + PluginOAuthClientConfig(identifier, client_credentials) if client_credentials is not None else None + ) + if plugin_provider: + controller = PluginToolProviderController( + entity=ToolProviderEntityWithPlugin.model_validate( + { + "identity": { + "author": "langgenius", + "name": "time", + "description": {"en_US": "Time"}, + "icon": "icon.svg", + "label": {"en_US": "Time"}, + }, + "credentials_schema": [{"name": "api_key", "type": "secret-input", "label": {"en_US": "API key"}}], + } + ), + plugin_id="langgenius/time", + plugin_unique_identifier=identifier, + tenant_id=tenant_id, + ) + monkeypatch.setattr(controller, "get_tool", lambda _tool_name: tool) provider_id = "00000000-0000-0000-0000-000000000002" builtin_provider = _builtin_provider( provider_id=provider_id, @@ -465,6 +493,7 @@ def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials( provider_id="time", tool_name="weekday", tenant_id=tenant_id, + credential_id=provider_id if plugin_provider else None, ) assert result == "runtime-tool" @@ -476,6 +505,11 @@ def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials( assert persisted.expires_at == refreshed.expires_at assert persisted.encrypted_credentials == json.dumps({"token": "encrypted"}) cache.delete.assert_called_once() + refresh = mock_oauth_handler_cls.return_value.refresh_credentials + refresh.assert_called_once() + assert refresh.call_args.kwargs["system_credentials"] == (client_credentials or {}) + expected_identifier = identifier if plugin_provider or client_credentials is not None else None + assert refresh.call_args.kwargs["plugin_unique_identifier"] == expected_identifier def test_get_tool_runtime_builtin_maps_oauth_refresh_failure_to_credential_error( diff --git a/api/tests/unit_tests/services/test_datasource_provider_service.py b/api/tests/unit_tests/services/test_datasource_provider_service.py index bdea7b37433e00..fe6dc2a054fdfe 100644 --- a/api/tests/unit_tests/services/test_datasource_provider_service.py +++ b/api/tests/unit_tests/services/test_datasource_provider_service.py @@ -20,6 +20,8 @@ from services.data_source.provider_service import DatasourceProviderService, get_current_user from services.plugin.dispatch_identity import PluginOAuthClientConfig +_PLUGIN_IDENTIFIER = f"org/plug:1.0.0@{'a' * 32}" + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -155,7 +157,7 @@ def patch_externals(self, config_overrides): "message": "ok", "data": { "provider": "prov", - "plugin_unique_identifier": "pui", + "plugin_unique_identifier": _PLUGIN_IDENTIFIER, "plugin_id": "org/plug", "is_authorized": False, "declaration": { @@ -178,6 +180,7 @@ def patch_externals(self, config_overrides): # Store handles for assertions self._enc = mock_enc self._redis = mock_redis + self._oauth = mock_oauth yield @pytest.fixture @@ -342,7 +345,15 @@ def test_should_return_empty_dict_when_credential_not_found(self, service, sqlit with patch("services.data_source.provider_service.get_current_user", return_value=mock_user): assert service.get_datasource_credentials("t1", "prov", "org/plug") == {} - def test_should_refresh_oauth_tokens_when_expired(self, service, sqlite_session, mock_user): + @pytest.mark.parametrize( + "oauth_client", + [ + None, + PluginOAuthClientConfig(_PLUGIN_IDENTIFIER, {}), + PluginOAuthClientConfig(_PLUGIN_IDENTIFIER, {"oc": "v"}), + ], + ) + def test_should_refresh_oauth_tokens_when_expired(self, service, sqlite_session, mock_user, oauth_client): """Expired OAuth credential (expires_at near zero) triggers a refresh.""" p = make_provider(auth_type="oauth2", expires_at=0, encrypted_credentials={"tok": "x"}) persist(sqlite_session, p) @@ -351,13 +362,19 @@ def test_should_refresh_oauth_tokens_when_expired(self, service, sqlite_session, patch.object( service, "get_oauth_client", - return_value=PluginOAuthClientConfig("org/plug:1.0.0@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", {"oc": "v"}), + return_value=oauth_client, ), patch.object(service, "decrypt_datasource_provider_credentials", return_value={"tok": "plain"}), ): service.get_datasource_credentials("t1", "prov", "org/plug") sqlite_session.expire_all() assert sqlite_session.get(DatasourceProvider, p.id).expires_at == 9999 + refresh = self._oauth.return_value.refresh_credentials + refresh.assert_called_once() + assert refresh.call_args.kwargs["system_credentials"] == ( + dict(oauth_client.credentials) if oauth_client is not None else {} + ) + assert refresh.call_args.kwargs["plugin_unique_identifier"] == _PLUGIN_IDENTIFIER def test_should_include_provider_name_when_refresh_fails(self, service, sqlite_session, mock_user): p = make_provider( diff --git a/api/tests/unit_tests/services/test_trigger_provider_service.py b/api/tests/unit_tests/services/test_trigger_provider_service.py index f1d9891a171440..56cf35f4972576 100644 --- a/api/tests/unit_tests/services/test_trigger_provider_service.py +++ b/api/tests/unit_tests/services/test_trigger_provider_service.py @@ -465,8 +465,9 @@ def test_delete_subscription_uses_real_caller_transaction( unsubscribe.assert_called_once() +@pytest.mark.parametrize("client_credentials", [None, {}, {"client": "system"}]) def test_refresh_oauth_token_persists_credentials_after_commit( - mocker, trigger_db: TriggerDatabase, provider_controller: Mock + mocker, trigger_db: TriggerDatabase, provider_controller: Mock, client_credentials: dict[str, str] | None ) -> None: subscription = trigger_db.add_subscription(credential_type=CredentialType.OAUTH2) _patch_provider(mocker, provider_controller) @@ -475,7 +476,11 @@ def test_refresh_oauth_token_persists_credentials_after_commit( mocker.patch.object( TriggerProviderService, "get_oauth_client", - return_value=PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, {"client": "system"}), + return_value=( + PluginOAuthClientConfig(provider_controller.plugin_unique_identifier, client_credentials) + if client_credentials is not None + else None + ), ) handler = Mock() handler.refresh_credentials.return_value = SimpleNamespace(credentials={"access": "plain"}, expires_at=1234) @@ -489,6 +494,11 @@ def test_refresh_oauth_token_persists_credentials_after_commit( assert persisted.credentials == {"access": "new"} # type: ignore[union-attr] assert persisted.credential_expires_at == 1234 # type: ignore[union-attr] clear_cache.assert_called_once() + handler.refresh_credentials.assert_called_once() + assert handler.refresh_credentials.call_args.kwargs["system_credentials"] == (client_credentials or {}) + assert handler.refresh_credentials.call_args.kwargs["plugin_unique_identifier"] == ( + provider_controller.plugin_unique_identifier + ) def test_refresh_oauth_rejects_missing_and_non_oauth(trigger_db: TriggerDatabase) -> None: