feat(api): isolate credentials from local packages (#39957)

This commit is contained in:
林玮 (Jade Lin) 2026-08-04 10:50:04 +08:00 committed by GitHub
parent 1b7a132afa
commit be0e2c03e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 349 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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