mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
feat(api): isolate credentials from local packages (#39957)
This commit is contained in:
parent
1b7a132afa
commit
be0e2c03e4
@ -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.")
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user