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

This commit is contained in:
林玮 (Jade Lin)
2026-08-04 02:50:04 +00:00
committed by GitHub
parent 1b7a132afa
commit be0e2c03e4
8 changed files with 349 additions and 33 deletions
@@ -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: