From be0e2c03e4779b20fbe02a2bcaca77acfe3a6699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Tue, 4 Aug 2026 10:50:04 +0800 Subject: [PATCH] feat(api): isolate credentials from local packages (#39957) --- api/core/plugin/entities/plugin_daemon.py | 10 +- api/core/plugin/impl/model_runtime.py | 3 +- api/core/plugin/plugin_service.py | 69 +++++-- api/core/provider_manager.py | 17 +- .../model_runtime/__mock/plugin_model.py | 2 + .../core/plugin/test_model_runtime_adapter.py | 9 + .../unit_tests/core/test_provider_manager.py | 178 +++++++++++++++++- .../services/plugin/test_plugin_service.py | 94 +++++++-- 8 files changed, 349 insertions(+), 33 deletions(-) diff --git a/api/core/plugin/entities/plugin_daemon.py b/api/core/plugin/entities/plugin_daemon.py index 4cf55ef8e3c..6b40ab4aaf7 100644 --- a/api/core/plugin/entities/plugin_daemon.py +++ b/api/core/plugin/entities/plugin_daemon.py @@ -12,7 +12,7 @@ from core.agent.plugin_entities import AgentProviderEntityWithPlugin from core.datasource.entities.datasource_entities import DatasourceProviderEntityWithPlugin from core.plugin.entities.base import BasePluginEntity from core.plugin.entities.parameters import PluginParameterOption -from core.plugin.entities.plugin import PluginDeclaration, PluginEntity +from core.plugin.entities.plugin import PluginDeclaration, PluginEntity, PluginInstallationSource from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin from core.trigger.entities.entities import TriggerProviderEntity @@ -83,6 +83,11 @@ class PluginModelSchemaEntity(BaseModel): model_config = ConfigDict(protected_namespaces=()) +class PluginModelProviderDeclaration(ProviderEntity): + plugin_unique_identifier: str = Field(description="The plugin unique identifier.") + installation_source: PluginInstallationSource | None = Field(description="The plugin installation source.") + + class PluginModelProviderEntity(BaseModel): id: str = Field(description="ID") created_at: datetime = Field(description="The created at time of the model provider.") @@ -91,6 +96,9 @@ class PluginModelProviderEntity(BaseModel): tenant_id: str = Field(description="The tenant ID.") plugin_unique_identifier: str = Field(description="The plugin unique identifier.") plugin_id: str = Field(description="The plugin ID.") + installation_source: PluginInstallationSource | None = Field( + default=None, description="The plugin installation source." + ) declaration: ProviderEntity = Field(description="The declaration of the model provider.") diff --git a/api/core/plugin/impl/model_runtime.py b/api/core/plugin/impl/model_runtime.py index c0fe1fe3d50..2957906bd1d 100644 --- a/api/core/plugin/impl/model_runtime.py +++ b/api/core/plugin/impl/model_runtime.py @@ -13,6 +13,7 @@ from configs import dify_config from core.llm_generator.output_parser.structured_output import ( invoke_llm_with_structured_output as invoke_llm_with_structured_output_helper, ) +from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration from core.plugin.impl.asset import PluginAssetManager from core.plugin.impl.model import PluginModelClient from core.plugin.plugin_service import PluginService @@ -131,7 +132,7 @@ class PluginModelRuntime(ModelRuntime): self._plugin_service = plugin_service @override - def fetch_model_providers(self) -> Sequence[ProviderEntity]: + def fetch_model_providers(self) -> Sequence[PluginModelProviderDeclaration]: return self._plugin_service.fetch_plugin_model_providers(tenant_id=self.tenant_id, client=self.client) @override diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index f70326aced8..e4bd153b54e 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -48,6 +48,7 @@ from core.plugin.entities.plugin_daemon import ( PluginInstallTaskStatus, PluginListResponse, PluginListWithoutTotalResponse, + PluginModelProviderDeclaration, PluginModelProviderEntity, PluginVerification, ) @@ -58,7 +59,6 @@ from core.plugin.impl.model import PluginModelClient from core.plugin.impl.plugin import PluginInstaller from extensions.ext_database import db from extensions.ext_redis import redis_client -from graphon.model_runtime.entities.provider_entities import ProviderEntity from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider from models.provider_ids import GenericProviderID, ModelProviderID from services.enterprise.plugin_manager_service import ( @@ -69,7 +69,9 @@ from services.errors.plugin import PluginInstallationForbiddenError from services.feature_service import FeatureService, PluginInstallationPermissionModel, PluginInstallationScope logger = logging.getLogger(__name__) -_provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity]) +_provider_entities_adapter: TypeAdapter[list[PluginModelProviderDeclaration]] = TypeAdapter( + list[PluginModelProviderDeclaration] +) class _RedisLock(Protocol): @@ -146,11 +148,44 @@ class PluginService: return provider.provider @classmethod - def _to_provider_entity(cls, provider: PluginModelProviderEntity) -> ProviderEntity: - declaration = provider.declaration.model_copy(deep=True) - declaration.provider = f"{provider.plugin_id}/{provider.provider}" - declaration.provider_name = cls._get_provider_short_name_alias(provider) - return declaration + def _to_provider_entity( + cls, + provider: PluginModelProviderEntity, + installation_source: PluginInstallationSource | None, + ) -> PluginModelProviderDeclaration: + return PluginModelProviderDeclaration.model_validate( + { + **provider.declaration.model_dump(), + "provider": f"{provider.plugin_id}/{provider.provider}", + "provider_name": cls._get_provider_short_name_alias(provider), + "plugin_unique_identifier": provider.plugin_unique_identifier, + "installation_source": installation_source, + } + ) + + @classmethod + def _resolve_model_provider_installation_sources( + cls, + tenant_id: str, + providers: Sequence[PluginModelProviderEntity], + ) -> Mapping[str, PluginInstallationSource]: + unresolved_plugin_ids = list( + dict.fromkeys(provider.plugin_id for provider in providers if provider.installation_source is None) + ) + if not unresolved_plugin_ids: + return {} + + try: + installations = cls.list_installations_from_ids(tenant_id, unresolved_plugin_ids) + except Exception: + logger.warning( + "Failed to resolve model provider installation sources for tenant %s.", + tenant_id, + exc_info=True, + ) + return {} + + return {installation.plugin_unique_identifier: installation.source for installation in installations} @classmethod def _encode_plugin_model_providers_cache_payload(cls, payload: bytes) -> bytes: @@ -206,7 +241,7 @@ class PluginService: @classmethod def _load_cached_plugin_model_providers_for_generation( cls, tenant_id: str, generation: int | None - ) -> tuple[tuple[ProviderEntity, ...] | None, bool]: + ) -> tuple[tuple[PluginModelProviderDeclaration, ...] | None, bool]: if generation is None: return None, False @@ -253,7 +288,7 @@ class PluginService: @classmethod def _store_cached_plugin_model_providers( - cls, tenant_id: str, generation: int, providers: Sequence[ProviderEntity] + cls, tenant_id: str, generation: int, providers: Sequence[PluginModelProviderDeclaration] ) -> None: cache_key = cls._get_plugin_model_providers_cache_key(tenant_id, generation) try: @@ -437,14 +472,22 @@ class PluginService: @classmethod def _fetch_plugin_model_providers_uncached( cls, tenant_id: str, client: PluginModelClient | None - ) -> tuple[ProviderEntity, ...]: + ) -> tuple[PluginModelProviderDeclaration, ...]: model_client = client or PluginModelClient() - return tuple(cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)) + providers = model_client.fetch_model_providers(tenant_id) + installation_sources = cls._resolve_model_provider_installation_sources(tenant_id, providers) + return tuple( + cls._to_provider_entity( + provider, + provider.installation_source or installation_sources.get(provider.plugin_unique_identifier), + ) + for provider in providers + ) @classmethod def _fetch_and_cache_plugin_model_providers( cls, tenant_id: str, client: PluginModelClient | None, *, refresh_generation: int | None - ) -> tuple[ProviderEntity, ...]: + ) -> tuple[PluginModelProviderDeclaration, ...]: providers = cls._fetch_plugin_model_providers_uncached(tenant_id, client) generation = cls._load_plugin_model_providers_generation(tenant_id) if generation is not None and generation == refresh_generation: @@ -467,7 +510,7 @@ class PluginService: @classmethod def fetch_plugin_model_providers( cls, *, tenant_id: str, client: PluginModelClient | None = None - ) -> Sequence[ProviderEntity]: + ) -> Sequence[PluginModelProviderDeclaration]: """ Fetch plugin model providers through the tenant-scoped plugin cache. diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index 0064252b64a..e63c155cca2 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -10,7 +10,7 @@ from enum import StrEnum from json import JSONDecodeError from typing import TYPE_CHECKING, Any, Protocol, Self -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from sqlalchemy import select from sqlalchemy.exc import IntegrityError @@ -34,6 +34,8 @@ from core.entities.provider_entities import ( from core.helper import encrypter from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType from core.helper.position_helper import is_filtered +from core.plugin.entities.plugin import PluginInstallationSource +from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration from enums.deployment_edition import DeploymentEdition from extensions import ext_hosting_provider from extensions.ext_database import db @@ -1529,6 +1531,19 @@ class ProviderManager: if provider_hosting_configuration is None or not provider_hosting_configuration.enabled: return SystemConfiguration(enabled=False) + try: + plugin_provider_entity = PluginModelProviderDeclaration.model_validate(provider_entity) + except ValidationError: + return SystemConfiguration(enabled=False) + + if plugin_provider_entity.installation_source != PluginInstallationSource.Marketplace: + return SystemConfiguration(enabled=False) + + from core.plugin.plugin_service import PluginService + + if not PluginService.is_plugin_verified(tenant_id, plugin_provider_entity.plugin_unique_identifier): + return SystemConfiguration(enabled=False) + # Convert provider_records to dict quota_type_to_provider_records_dict: dict[ProviderQuotaType, Provider] = {} for provider_record in provider_records: diff --git a/api/tests/integration_tests/model_runtime/__mock/plugin_model.py b/api/tests/integration_tests/model_runtime/__mock/plugin_model.py index 9b2bb963d2f..7be1e2744fe 100644 --- a/api/tests/integration_tests/model_runtime/__mock/plugin_model.py +++ b/api/tests/integration_tests/model_runtime/__mock/plugin_model.py @@ -4,6 +4,7 @@ from collections.abc import Generator, Sequence from decimal import Decimal from json import dumps +from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginModelProviderEntity from core.plugin.impl.model import PluginModelClient @@ -41,6 +42,7 @@ class MockModelClass(PluginModelClient): tenant_id=tenant_id, plugin_unique_identifier="langgenius/openai/openai", plugin_id="langgenius/openai", + installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( provider="openai", label=I18nObject( 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 a6f80505e62..a436116f838 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 @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, Mock, patch, sentinel import pytest +from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginModelProviderEntity from core.plugin.impl import model_runtime as model_runtime_module from core.plugin.impl.model import PluginModelClient @@ -93,6 +94,7 @@ def _build_plugin_model_provider(*, tenant_id: str, provider: str = "openai") -> tenant_id=tenant_id, plugin_unique_identifier=f"langgenius/{provider}/{provider}", plugin_id=f"langgenius/{provider}", + installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( provider=provider, label=I18nObject(en_US=provider.title()), @@ -116,6 +118,7 @@ class TestPluginModelRuntime: tenant_id="tenant", plugin_unique_identifier="langgenius/openai/openai", plugin_id="langgenius/openai", + installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( provider="openai", label=I18nObject(en_US="OpenAI"), @@ -145,6 +148,7 @@ class TestPluginModelRuntime: tenant_id="tenant", plugin_unique_identifier="acme/openai/openai", plugin_id="acme/openai", + installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( provider="openai", label=I18nObject(en_US="Acme OpenAI"), @@ -160,6 +164,7 @@ class TestPluginModelRuntime: tenant_id="tenant", plugin_unique_identifier="langgenius/openai/openai", plugin_id="langgenius/openai", + installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( provider="openai", label=I18nObject(en_US="OpenAI"), @@ -187,6 +192,7 @@ class TestPluginModelRuntime: tenant_id="tenant", plugin_unique_identifier="langgenius/gemini/google", plugin_id="langgenius/gemini", + installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( provider="google", label=I18nObject(en_US="Google"), @@ -821,6 +827,7 @@ def test_get_provider_icon_reads_requested_variant_and_detects_svg_mime(monkeypa tenant_id="tenant", plugin_unique_identifier="langgenius/openai/openai", plugin_id="langgenius/openai", + installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( provider="openai", label=I18nObject(en_US="OpenAI"), @@ -857,6 +864,7 @@ def test_get_provider_icon_rejects_unsupported_types_and_missing_variants() -> N tenant_id="tenant", plugin_unique_identifier="langgenius/openai/openai", plugin_id="langgenius/openai", + installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( provider="openai", label=I18nObject(en_US="OpenAI"), @@ -989,6 +997,7 @@ def test_get_provider_schema_supports_short_alias_and_rejects_invalid_provider() tenant_id="tenant", plugin_unique_identifier="langgenius/openai/openai", plugin_id="langgenius/openai", + installation_source=PluginInstallationSource.Marketplace, declaration=ProviderEntity( provider="openai", label=I18nObject(en_US="OpenAI"), diff --git a/api/tests/unit_tests/core/test_provider_manager.py b/api/tests/unit_tests/core/test_provider_manager.py index 7c9547a6492..dddddf76f10 100644 --- a/api/tests/unit_tests/core/test_provider_manager.py +++ b/api/tests/unit_tests/core/test_provider_manager.py @@ -7,10 +7,19 @@ from sqlalchemy import Engine, event, select from sqlalchemy.orm import Session, sessionmaker from core import provider_manager as provider_manager_module -from core.entities.provider_entities import ModelSettings +from core.entities.provider_entities import ( + CustomConfiguration, + CustomProviderConfiguration, + ModelSettings, + ProviderQuotaType, +) +from core.hosting_configuration import HostingProvider, TrialHostingQuota +from core.plugin.entities.plugin import PluginInstallationSource +from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.model_entities import ModelType +from graphon.model_runtime.entities.provider_entities import ConfigurateMethod from models.base import TypeBase from models.provider import ( LoadBalancingModelConfig, @@ -71,6 +80,39 @@ def provider_db(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Itera yield request_session +def _build_plugin_provider_declaration( + installation_source: PluginInstallationSource | None, +) -> PluginModelProviderDeclaration: + return PluginModelProviderDeclaration( + provider="langgenius/openai/openai", + plugin_unique_identifier="langgenius/openai:1.0.0@checksum", + installation_source=installation_source, + label=I18nObject(en_US="OpenAI"), + supported_model_types=[ModelType.LLM], + configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], + ) + + +def _build_hosting_provider() -> HostingProvider: + return HostingProvider( + enabled=True, + credentials={"api_key": "system-secret"}, + quotas=[TrialHostingQuota(quota_limit=100)], + ) + + +def _build_trial_provider_record() -> Provider: + return Provider( + tenant_id="tenant-id", + provider_name="openai", + provider_type=ProviderType.SYSTEM, + quota_type=ProviderQuotaType.TRIAL, + quota_limit=100, + quota_used=0, + is_valid=True, + ) + + class _FakeRedis: def __init__(self) -> None: self.store: dict[str, str] = {} @@ -180,6 +222,140 @@ def test__to_model_settings(mock_provider_entity, provider_db: Session): assert result[0].load_balancing_configs[1].name == "first" +@pytest.mark.parametrize( + "installation_source", + [ + None, + PluginInstallationSource.Github, + PluginInstallationSource.Package, + PluginInstallationSource.Remote, + ], +) +def test_to_system_configuration_rejects_non_marketplace_provider( + installation_source: PluginInstallationSource | None, +) -> None: + provider_entity = _build_plugin_provider_declaration(installation_source) + manager = _build_provider_manager() + + with ( + patch.object(manager, "_choice_current_using_quota_type") as choose_quota, + patch( + "core.provider_manager.ext_hosting_provider.hosting_configuration.provider_map", + {provider_entity.provider: _build_hosting_provider()}, + ), + ): + configuration = manager._to_system_configuration("tenant-id", provider_entity, []) + + assert configuration.enabled is False + assert configuration.credentials is None + choose_quota.assert_not_called() + + +def test_to_system_configuration_rejects_unverified_marketplace_provider() -> None: + provider_entity = _build_plugin_provider_declaration(PluginInstallationSource.Marketplace) + manager = _build_provider_manager() + + with ( + patch.object(manager, "_choice_current_using_quota_type") as choose_quota, + patch( + "core.provider_manager.ext_hosting_provider.hosting_configuration.provider_map", + {provider_entity.provider: _build_hosting_provider()}, + ), + patch( + "core.plugin.plugin_service.PluginService.is_plugin_verified", + return_value=False, + ) as is_plugin_verified, + ): + configuration = manager._to_system_configuration("tenant-id", provider_entity, []) + + assert configuration.enabled is False + assert configuration.credentials is None + is_plugin_verified.assert_called_once_with("tenant-id", provider_entity.plugin_unique_identifier) + choose_quota.assert_not_called() + + +def test_to_system_configuration_never_returns_hosting_credentials_for_package_with_valid_quota() -> None: + provider_entity = _build_plugin_provider_declaration(PluginInstallationSource.Package) + manager = _build_provider_manager() + + with patch( + "core.provider_manager.ext_hosting_provider.hosting_configuration.provider_map", + {provider_entity.provider: _build_hosting_provider()}, + ): + configuration = manager._to_system_configuration( + "tenant-id", + provider_entity, + [_build_trial_provider_record()], + ) + + assert configuration.enabled is False + assert configuration.credentials is None + + +def test_to_system_configuration_preserves_marketplace_behavior() -> None: + provider_entity = _build_plugin_provider_declaration(PluginInstallationSource.Marketplace) + manager = _build_provider_manager() + + with ( + patch( + "core.provider_manager.ext_hosting_provider.hosting_configuration.provider_map", + {provider_entity.provider: _build_hosting_provider()}, + ), + patch( + "core.plugin.plugin_service.PluginService.is_plugin_verified", + return_value=True, + ) as is_plugin_verified, + ): + configuration = manager._to_system_configuration( + "tenant-id", + provider_entity, + [_build_trial_provider_record()], + ) + + assert configuration.enabled is True + assert configuration.credentials == {"api_key": "system-secret"} + assert configuration.current_quota_type == ProviderQuotaType.TRIAL + is_plugin_verified.assert_called_once_with("tenant-id", provider_entity.plugin_unique_identifier) + + +def test_package_provider_keeps_custom_configuration() -> None: + provider_entity = _build_plugin_provider_declaration(PluginInstallationSource.Package) + manager = _build_provider_manager() + provider_factory = Mock() + provider_factory.get_providers.return_value = [provider_entity] + custom_configuration = CustomConfiguration( + provider=CustomProviderConfiguration(credentials={"api_key": "user-secret"}) + ) + + with ( + patch.object(manager, "_get_all_providers", return_value={provider_entity.provider: []}), + patch.object( + manager, + "_init_trial_provider_records", + return_value={provider_entity.provider: []}, + ), + patch.object(manager, "_get_all_provider_models", return_value={}), + patch.object(manager, "_get_all_preferred_model_providers", return_value={}), + patch.object(manager, "_get_all_provider_model_settings", return_value={}), + patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}), + patch.object(manager, "_get_all_provider_model_credentials", return_value={}), + patch.object(manager, "_get_all_provider_credentials", return_value={}), + patch.object(manager, "_to_custom_configuration", return_value=custom_configuration), + patch.object(manager, "_to_model_settings", return_value=[]), + patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory), + patch( + "core.provider_manager.ext_hosting_provider.hosting_configuration.provider_map", + {provider_entity.provider: _build_hosting_provider()}, + ), + ): + configuration = manager.get_configurations("tenant-id").get(provider_entity.provider) + + assert configuration is not None + assert configuration.system_configuration.enabled is False + assert configuration.custom_configuration.provider is not None + assert configuration.custom_configuration.provider.credentials == {"api_key": "user-secret"} + + def test__to_model_settings_only_one_lb(mock_provider_entity, provider_db: Session): # Mocking the inputs diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service.py b/api/tests/unit_tests/services/plugin/test_plugin_service.py index ccacc5ca52d..f34e755bb18 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service.py @@ -11,8 +11,13 @@ from sqlalchemy.orm import Session from core.helper.model_provider_cache import ProviderCredentialsCacheType from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource -from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity -from core.provider_manager import ProviderConfigurationCacheSource +from core.plugin.entities.plugin_daemon import ( + PluginInstallTask, + PluginInstallTaskStatus, + PluginModelProviderDeclaration, + PluginModelProviderEntity, +) +from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity from models.provider import Provider, ProviderCredential, ProviderType, TenantPreferredModelProvider @@ -24,24 +29,35 @@ OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222" USER_ID = "33333333-3333-3333-3333-333333333333" -def _build_provider_entity(provider: str = "openai") -> ProviderEntity: - return ProviderEntity( +def _build_provider_entity( + provider: str = "openai", + installation_source: PluginInstallationSource | None = PluginInstallationSource.Marketplace, +) -> PluginModelProviderDeclaration: + return PluginModelProviderDeclaration( provider=f"langgenius/{provider}/{provider}", + plugin_unique_identifier=f"langgenius/{provider}:1.0.0@checksum", + installation_source=installation_source, label=I18nObject(en_US=provider.title()), supported_model_types=[], configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], ) -def _build_plugin_model_provider(*, tenant_id: str = "tenant-1", provider: str = "openai") -> PluginModelProviderEntity: +def _build_plugin_model_provider( + *, + tenant_id: str = "tenant-1", + provider: str = "openai", + installation_source: PluginInstallationSource | None = PluginInstallationSource.Marketplace, +) -> PluginModelProviderEntity: return PluginModelProviderEntity( id=uuid.uuid4().hex, created_at=datetime.datetime.now(), 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@checksum", plugin_id=f"langgenius/{provider}", + installation_source=installation_source, declaration=ProviderEntity( provider=provider, label=I18nObject(en_US=provider.title()), @@ -137,7 +153,7 @@ class TestPluginModelProviderCache: """Large provider metadata payloads are compressed before being stored in Redis.""" large_provider = _build_provider_entity() large_provider.label = I18nObject(en_US="OpenAI " * 10000) - raw_payload = TypeAdapter(list[ProviderEntity]).dump_json([large_provider]) + raw_payload = TypeAdapter(list[PluginModelProviderDeclaration]).dump_json([large_provider]) cache_key = _provider_cache_key("tenant-1", 0) with ( @@ -164,7 +180,7 @@ class TestPluginModelProviderCache: """Compressed tenant cache entries are decoded before provider schema validation.""" cached_provider = _build_provider_entity() cached_provider.label = I18nObject(en_US="OpenAI " * 10000) - cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider]) + cached_payload = TypeAdapter(list[PluginModelProviderDeclaration]).dump_json([cached_provider]) generation_key = _provider_generation_key("tenant-1") cache_key = _provider_cache_key("tenant-1", 0) @@ -182,6 +198,7 @@ class TestPluginModelProviderCache: result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client) assert [provider.provider for provider in result] == ["langgenius/openai/openai"] + assert result[0].plugin_unique_identifier == "langgenius/openai:1.0.0@checksum" assert result[0].label.en_us == "OpenAI " * 10000 client.fetch_model_providers.assert_not_called() redis_client.setex.assert_not_called() @@ -189,9 +206,9 @@ class TestPluginModelProviderCache: redis_client.mget.assert_called_once_with([cache_key]) def test_fetch_plugin_model_providers_returns_cached_provider_without_calling_daemon(self) -> None: - """A valid tenant cache entry is reused across runtime calls without plugin daemon access.""" - cached_provider = _build_provider_entity() - cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider]) + """A cached package source remains available to the system configuration guard.""" + cached_provider = _build_provider_entity(installation_source=PluginInstallationSource.Package) + cached_payload = TypeAdapter(list[PluginModelProviderDeclaration]).dump_json([cached_provider]) generation_key = _provider_generation_key("tenant-1") cache_key = _provider_cache_key("tenant-1", 0) @@ -205,13 +222,33 @@ class TestPluginModelProviderCache: result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client) assert [provider.provider for provider in result] == ["langgenius/openai/openai"] + assert result[0].installation_source == PluginInstallationSource.Package + provider_manager = ProviderManager(model_runtime=Mock()) + with ( + patch( + "core.provider_manager.ext_hosting_provider.hosting_configuration.provider_map", + {result[0].provider: SimpleNamespace(enabled=True)}, + ), + patch("core.plugin.plugin_service.PluginService.is_plugin_verified") as is_plugin_verified, + ): + system_configuration = provider_manager._to_system_configuration("tenant-1", result[0], []) + + assert system_configuration.enabled is False + is_plugin_verified.assert_not_called() client.fetch_model_providers.assert_not_called() redis_client.setex.assert_not_called() redis_client.get.assert_called_once_with(generation_key) redis_client.mget.assert_called_once_with([cache_key]) - def test_fetch_plugin_model_providers_deletes_invalid_cache_and_refetches(self) -> None: - """Invalid generation-scoped cache payloads are removed before falling back to the daemon.""" + def test_fetch_plugin_model_providers_invalidates_legacy_cache_without_plugin_identity(self) -> None: + """Legacy provider cache entries are refreshed before they can reach system configuration.""" + legacy_provider = ProviderEntity( + provider="langgenius/openai/openai", + label=I18nObject(en_US="OpenAI"), + supported_model_types=[], + configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], + ) + legacy_payload = TypeAdapter(list[ProviderEntity]).dump_json([legacy_provider]) generation_key = _provider_generation_key("tenant-1") cache_key = _provider_cache_key("tenant-1", 0) with ( @@ -219,7 +256,7 @@ class TestPluginModelProviderCache: patch(f"{MODULE}.dify_config") as mock_config, ): redis_client.get.side_effect = [None, None, None] - redis_client.mget.side_effect = [["not-json"], [None]] + redis_client.mget.side_effect = [[legacy_payload], [None]] mock_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL = 86400 client = Mock() client.fetch_model_providers.return_value = [_build_plugin_model_provider()] @@ -253,12 +290,37 @@ class TestPluginModelProviderCache: assert [provider.provider for provider in first] == ["langgenius/openai/openai"] assert [provider.provider for provider in second] == ["langgenius/openai/openai"] + assert first[0].plugin_unique_identifier == "langgenius/openai:1.0.0@checksum" assert client.fetch_model_providers.call_count == 2 redis_client.get.assert_not_called() redis_client.mget.assert_not_called() redis_client.setex.assert_not_called() redis_client.lock.assert_not_called() + def test_fetch_plugin_model_providers_resolves_missing_installation_source(self) -> None: + provider = _build_plugin_model_provider(installation_source=None) + installation = SimpleNamespace( + plugin_unique_identifier=provider.plugin_unique_identifier, + source=PluginInstallationSource.Package, + ) + + from core.plugin.plugin_service import PluginService + + with ( + patch(f"{MODULE}.dify_config") as config, + patch.object( + PluginService, "list_installations_from_ids", return_value=[installation] + ) as list_installations, + ): + config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED = False + client = Mock() + client.fetch_model_providers.return_value = [provider] + + result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client) + + list_installations.assert_called_once_with("tenant-1", [provider.plugin_id]) + assert result[0].installation_source == PluginInstallationSource.Package + def test_fetch_plugin_model_providers_refetches_when_cache_read_fails(self) -> None: """Redis read failures do not block provider discovery for the tenant.""" with patch(f"{MODULE}.redis_client") as redis_client: @@ -309,7 +371,7 @@ class TestPluginModelProviderCache: def test_fetch_plugin_model_providers_waits_for_concurrent_refresh_cache_fill(self) -> None: """A cache miss waits for the active tenant refresh instead of stampeding the daemon.""" cached_provider = _build_provider_entity() - cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider]) + cached_payload = TypeAdapter(list[PluginModelProviderDeclaration]).dump_json([cached_provider]) cache_key = _provider_cache_key("tenant-1", 0) with ( @@ -636,7 +698,7 @@ class TestPluginModelProviderCache: def test_fetch_plugin_model_providers_reuses_cached_empty_provider_list(self) -> None: """A cached empty list should prevent repeated daemon fetches for tenants without plugin models.""" - empty_payload = TypeAdapter(list[ProviderEntity]).dump_json([]) + empty_payload = TypeAdapter(list[PluginModelProviderDeclaration]).dump_json([]) cache_key = _provider_cache_key("tenant-1", 0) with patch(f"{MODULE}.redis_client") as redis_client: