mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 04:32:58 +08:00
Merge branch 'fix/builtin-tool-default-credential-lts-1.13.x' into deploy/enterprise
Pull in: fix(tools): scope builtin tool default-credential clear to tenant Brings the multi-default credential bugfix into the enterprise deploy branch for dev-environment validation.
This commit is contained in:
commit
86bf2c64c7
1
.github/workflows/api-tests.yml
vendored
1
.github/workflows/api-tests.yml
vendored
@ -25,7 +25,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
|
||||
steps:
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity
|
||||
@ -15,12 +16,17 @@ from dify_graph.nodes.llm.protocols import CredentialsProvider, ModelFactory
|
||||
class DifyCredentialsProvider:
|
||||
tenant_id: str
|
||||
provider_manager: ProviderManager
|
||||
credentials_cache: dict[tuple[str, str], dict[str, Any]]
|
||||
|
||||
def __init__(self, tenant_id: str, provider_manager: ProviderManager | None = None) -> None:
|
||||
self.tenant_id = tenant_id
|
||||
self.provider_manager = provider_manager or ProviderManager()
|
||||
self.credentials_cache = {}
|
||||
|
||||
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]:
|
||||
if (provider_name, model_name) in self.credentials_cache:
|
||||
return deepcopy(self.credentials_cache[(provider_name, model_name)])
|
||||
|
||||
provider_configurations = self.provider_manager.get_configurations(self.tenant_id)
|
||||
provider_configuration = provider_configurations.get(provider_name)
|
||||
if not provider_configuration:
|
||||
@ -35,6 +41,7 @@ class DifyCredentialsProvider:
|
||||
if credentials is None:
|
||||
raise ProviderTokenNotInitError(f"Model {model_name} credentials is not initialized.")
|
||||
|
||||
self.credentials_cache[(provider_name, model_name)] = deepcopy(credentials)
|
||||
return credentials
|
||||
|
||||
|
||||
@ -44,7 +51,7 @@ class DifyModelFactory:
|
||||
|
||||
def __init__(self, tenant_id: str, model_manager: ModelManager | None = None) -> None:
|
||||
self.tenant_id = tenant_id
|
||||
self.model_manager = model_manager or ModelManager()
|
||||
self.model_manager = model_manager or ModelManager(enable_credentials_cache=True)
|
||||
|
||||
def init_model_instance(self, provider_name: str, model_name: str) -> ModelInstance:
|
||||
return self.model_manager.get_model_instance(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
|
||||
from copy import deepcopy
|
||||
from typing import IO, Any, Literal, Optional, Union, cast, overload
|
||||
|
||||
from configs import dify_config
|
||||
@ -33,11 +34,13 @@ class ModelInstance:
|
||||
Model instance class
|
||||
"""
|
||||
|
||||
def __init__(self, provider_model_bundle: ProviderModelBundle, model: str):
|
||||
def __init__(self, provider_model_bundle: ProviderModelBundle, model: str, credentials: dict | None = None):
|
||||
self.provider_model_bundle = provider_model_bundle
|
||||
self.model_name = model
|
||||
self.provider = provider_model_bundle.configuration.provider.provider
|
||||
self.credentials = self._fetch_credentials_from_bundle(provider_model_bundle, model)
|
||||
if credentials is None:
|
||||
credentials = self._fetch_credentials_from_bundle(provider_model_bundle, model)
|
||||
self.credentials = credentials
|
||||
# Runtime LLM invocation fields.
|
||||
self.parameters: Mapping[str, Any] = {}
|
||||
self.stop: Sequence[str] = ()
|
||||
@ -477,8 +480,10 @@ class ModelInstance:
|
||||
|
||||
|
||||
class ModelManager:
|
||||
def __init__(self):
|
||||
def __init__(self, enable_credentials_cache: bool = False):
|
||||
self._provider_manager = ProviderManager()
|
||||
self._credentials_cache: dict[tuple[str, str, str, str], Any] = {}
|
||||
self._enable_credentials_cache = enable_credentials_cache
|
||||
|
||||
def get_model_instance(self, tenant_id: str, provider: str, model_type: ModelType, model: str) -> ModelInstance:
|
||||
"""
|
||||
@ -496,7 +501,19 @@ class ModelManager:
|
||||
tenant_id=tenant_id, provider=provider, model_type=model_type
|
||||
)
|
||||
|
||||
return ModelInstance(provider_model_bundle, model)
|
||||
cred_cache_key = (tenant_id, provider, model_type.value, model)
|
||||
|
||||
if cred_cache_key in self._credentials_cache:
|
||||
return ModelInstance(
|
||||
provider_model_bundle,
|
||||
model,
|
||||
deepcopy(self._credentials_cache[cred_cache_key]),
|
||||
)
|
||||
|
||||
ret = ModelInstance(provider_model_bundle, model)
|
||||
if self._enable_credentials_cache:
|
||||
self._credentials_cache[cred_cache_key] = deepcopy(ret.credentials)
|
||||
return ret
|
||||
|
||||
def get_default_provider_model_name(self, tenant_id: str, model_type: ModelType) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
|
||||
@ -56,12 +56,37 @@ from services.feature_service import FeatureService
|
||||
|
||||
class ProviderManager:
|
||||
"""
|
||||
ProviderManager is a class that manages the model providers includes Hosting and Customize Model Providers.
|
||||
ProviderManager manages tenant-scoped model provider configuration.
|
||||
|
||||
The runtime adapter is injected by the composition layer so this class stays
|
||||
focused on configuration assembly instead of constructing plugin runtimes.
|
||||
Request-bound managers may carry caller identity in that runtime, and the
|
||||
resulting ``ProviderConfiguration`` objects must reuse it for downstream
|
||||
model-type and schema lookups.
|
||||
|
||||
Configuration assembly is cached per manager instance so call chains that
|
||||
share one request-scoped manager can reuse the same provider graph instead
|
||||
of rebuilding it for every lookup. Call ``clear_configurations_cache()``
|
||||
when a long-lived manager needs to observe writes performed within the same
|
||||
instance scope.
|
||||
"""
|
||||
|
||||
decoding_rsa_key: Any | None
|
||||
decoding_cipher_rsa: Any | None
|
||||
_configurations_cache: dict[str, ProviderConfigurations]
|
||||
|
||||
def __init__(self):
|
||||
self.decoding_rsa_key = None
|
||||
self.decoding_cipher_rsa = None
|
||||
self._configurations_cache = {}
|
||||
|
||||
def clear_configurations_cache(self, tenant_id: str | None = None) -> None:
|
||||
"""Drop assembled provider configurations cached on this manager instance."""
|
||||
if tenant_id is None:
|
||||
self._configurations_cache.clear()
|
||||
return
|
||||
|
||||
self._configurations_cache.pop(tenant_id, None)
|
||||
|
||||
def get_configurations(self, tenant_id: str) -> ProviderConfigurations:
|
||||
"""
|
||||
@ -100,6 +125,10 @@ class ProviderManager:
|
||||
:param tenant_id:
|
||||
:return:
|
||||
"""
|
||||
cached_configurations = self._configurations_cache.get(tenant_id)
|
||||
if cached_configurations is not None:
|
||||
return cached_configurations
|
||||
|
||||
# Get all provider records of the workspace
|
||||
provider_name_to_provider_records_dict = self._get_all_providers(tenant_id)
|
||||
|
||||
@ -258,6 +287,8 @@ class ProviderManager:
|
||||
|
||||
provider_configurations[str(provider_id_entity)] = provider_configuration
|
||||
|
||||
self._configurations_cache[tenant_id] = provider_configurations
|
||||
|
||||
# Return the encapsulated object
|
||||
return provider_configurations
|
||||
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
class _EventHook:
|
||||
def __init__(self):
|
||||
self._handlers = []
|
||||
|
||||
def __iadd__(self, handler):
|
||||
self._handlers.append(handler)
|
||||
return self
|
||||
|
||||
def __isub__(self, handler):
|
||||
try:
|
||||
self._handlers.remove(handler)
|
||||
except ValueError:
|
||||
pass
|
||||
return self
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
for handler in list(self._handlers):
|
||||
handler(*args, **kwargs)
|
||||
|
||||
|
||||
class Events:
|
||||
def __getattr__(self, name):
|
||||
hook = _EventHook()
|
||||
setattr(self, name, hook)
|
||||
return hook
|
||||
@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.13.3"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
"aliyun-log-python-sdk~=0.9.37",
|
||||
@ -34,13 +34,13 @@ dependencies = [
|
||||
"json-repair>=0.55.1",
|
||||
"jsonschema>=4.25.1",
|
||||
"langfuse~=2.51.3",
|
||||
"langsmith~=0.7.16",
|
||||
"langsmith~=0.7.31",
|
||||
"markdown~=3.10.2",
|
||||
"mlflow-skinny>=3.0.0",
|
||||
"numpy~=1.26.4",
|
||||
"openpyxl~=3.1.5",
|
||||
"opik~=1.10.37",
|
||||
"litellm==1.82.6", # Pinned to avoid madoka dependency issue
|
||||
"litellm==1.83.7", # Pinned to avoid madoka dependency issue
|
||||
"opentelemetry-api==1.28.0",
|
||||
"opentelemetry-distro==0.49b0",
|
||||
"opentelemetry-exporter-otlp==1.28.0",
|
||||
@ -88,7 +88,7 @@ dependencies = [
|
||||
"flask-restx~=1.3.2",
|
||||
"packaging~=23.2",
|
||||
"croniter>=6.0.0",
|
||||
"weaviate-client==4.20.4",
|
||||
"weaviate-client==4.20.5",
|
||||
"apscheduler>=3.11.0",
|
||||
"weave>=0.52.16",
|
||||
"fastopenapi[flask]>=0.7.0",
|
||||
@ -103,6 +103,13 @@ packages = []
|
||||
[tool.uv]
|
||||
default-groups = ["storage", "tools", "vdb"]
|
||||
package = false
|
||||
# litellm==1.83.7 pins jsonschema==4.23.0 and python-dotenv==1.0.1; overrides keep
|
||||
# resolution aligned with our direct deps (see dependency-groups / CI lock checks).
|
||||
override-dependencies = [
|
||||
"jsonschema>=4.25.1",
|
||||
"python-dotenv==1.2.2",
|
||||
"pyarrow>=18.0.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@ -224,7 +231,7 @@ vdb = [
|
||||
"tidb-vector==0.0.15",
|
||||
"upstash-vector==0.8.0",
|
||||
"volcengine-compat~=1.0.0",
|
||||
"weaviate-client==4.20.4",
|
||||
"weaviate-client==4.20.5",
|
||||
"xinference-client~=2.3.1",
|
||||
"mo-vector~=0.1.13",
|
||||
"mysql-connector-python>=9.3.0",
|
||||
@ -255,5 +262,5 @@ ignore_errors = true
|
||||
project-includes = ["."]
|
||||
project-excludes = [".venv", "migrations/"]
|
||||
python-platform = "linux"
|
||||
python-version = "3.11.0"
|
||||
python-version = "3.12.0"
|
||||
infer-with-first-use = false
|
||||
|
||||
@ -50,6 +50,6 @@
|
||||
"reportUntypedFunctionDecorator": "hint",
|
||||
"reportUnnecessaryTypeIgnoreComment": "hint",
|
||||
"reportAttributeAccessIssue": "hint",
|
||||
"pythonVersion": "3.11",
|
||||
"pythonVersion": "3.12",
|
||||
"pythonPlatform": "All"
|
||||
}
|
||||
@ -5,6 +5,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cachetools.func import ttl_cache
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from configs import dify_config
|
||||
@ -98,7 +99,9 @@ def try_join_default_workspace(account_id: str) -> None:
|
||||
|
||||
|
||||
class EnterpriseService:
|
||||
|
||||
@classmethod
|
||||
@ttl_cache(ttl=5)
|
||||
def get_info(cls):
|
||||
return EnterpriseRequest.send_request("GET", "/info")
|
||||
|
||||
|
||||
@ -416,9 +416,9 @@ class BuiltinToolManageService:
|
||||
if target_provider is None:
|
||||
raise ValueError("provider not found")
|
||||
|
||||
# clear default provider
|
||||
# clear default provider (tenant-scoped: only one default per provider per workspace)
|
||||
session.query(BuiltinToolProvider).filter_by(
|
||||
tenant_id=tenant_id, user_id=user_id, provider=provider, is_default=True
|
||||
tenant_id=tenant_id, provider=provider, is_default=True
|
||||
).update({"is_default": False})
|
||||
|
||||
# set new default provider
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
from unittest.mock import Mock, PropertyMock, patch
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@ -6,7 +8,14 @@ from core.entities.provider_entities import ModelSettings
|
||||
from core.provider_manager import ProviderManager
|
||||
from dify_graph.model_runtime.entities.common_entities import I18nObject
|
||||
from dify_graph.model_runtime.entities.model_entities import ModelType
|
||||
from models.provider import LoadBalancingModelConfig, ProviderModelSetting
|
||||
from models.provider import LoadBalancingModelConfig, ProviderModelSetting, TenantDefaultModel
|
||||
from models.provider_ids import ModelProviderID
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _build_session_context(session: Mock):
|
||||
"""Used with patch(Session, return_value=...) to emulate ``with Session(...) as s``."""
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -228,3 +237,418 @@ def test_get_default_model_uses_first_available_active_model():
|
||||
assert saved_default_model.model_name == "gpt-3.5-turbo"
|
||||
assert saved_default_model.provider_name == "openai"
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_get_default_model_returns_none_when_no_default_or_active_models():
|
||||
mock_session = Mock()
|
||||
mock_session.scalar.return_value = None
|
||||
provider_configurations = Mock()
|
||||
provider_configurations.get_models.return_value = []
|
||||
manager = ProviderManager()
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.db.session", mock_session),
|
||||
patch.object(manager, "get_configurations", return_value=provider_configurations),
|
||||
patch("core.provider_manager.ModelProviderFactory") as mock_factory_cls,
|
||||
):
|
||||
result = manager.get_default_model("tenant-id", ModelType.LLM)
|
||||
|
||||
assert result is None
|
||||
provider_configurations.get_models.assert_called_once_with(model_type=ModelType.LLM, only_active=True)
|
||||
mock_factory_cls.assert_not_called()
|
||||
mock_session.add.assert_not_called()
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_get_default_model_uses_tenant_id_factory_for_existing_default_record():
|
||||
existing_default_model = TenantDefaultModel(
|
||||
tenant_id="tenant-id",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
mock_session = Mock()
|
||||
mock_session.scalar.return_value = existing_default_model
|
||||
manager = ProviderManager()
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.db.session", mock_session),
|
||||
patch("core.provider_manager.ModelProviderFactory") as mock_factory_cls,
|
||||
):
|
||||
mock_factory_cls.return_value.get_provider_schema.return_value = Mock(
|
||||
provider="openai",
|
||||
label=I18nObject(en_US="OpenAI", zh_Hans="OpenAI"),
|
||||
icon_small=I18nObject(en_US="icon_small.png", zh_Hans="icon_small.png"),
|
||||
supported_model_types=[ModelType.LLM],
|
||||
)
|
||||
|
||||
result = manager.get_default_model("tenant-id", ModelType.LLM)
|
||||
|
||||
mock_factory_cls.assert_called_once_with("tenant-id")
|
||||
assert result is not None
|
||||
assert result.model == "gpt-4"
|
||||
assert result.provider.provider == "openai"
|
||||
|
||||
|
||||
def test_get_configurations_uses_tenant_id_factory_and_adds_provider_aliases():
|
||||
manager = ProviderManager()
|
||||
provider_records = {"openai": [SimpleNamespace(provider_name="openai")]}
|
||||
provider_model_records = {"openai": [SimpleNamespace(provider_name="openai")]}
|
||||
preferred_provider_records = {"openai": SimpleNamespace(preferred_provider_type="system")}
|
||||
|
||||
with (
|
||||
patch.object(manager, "_get_all_providers", return_value=provider_records),
|
||||
patch.object(manager, "_init_trial_provider_records", return_value=provider_records),
|
||||
patch.object(manager, "_get_all_provider_models", return_value=provider_model_records),
|
||||
patch.object(manager, "_get_all_preferred_model_providers", return_value=preferred_provider_records),
|
||||
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("core.provider_manager.ModelProviderFactory") as mock_factory_cls,
|
||||
):
|
||||
mock_factory_cls.return_value.get_providers.return_value = []
|
||||
|
||||
result = manager.get_configurations("tenant-id")
|
||||
|
||||
expected_alias = str(ModelProviderID("openai"))
|
||||
mock_factory_cls.assert_called_once_with("tenant-id")
|
||||
assert result.tenant_id == "tenant-id"
|
||||
assert expected_alias in provider_records
|
||||
assert expected_alias in provider_model_records
|
||||
assert expected_alias in preferred_provider_records
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider_name", "expected_provider_names"),
|
||||
[
|
||||
("openai", ["openai", "langgenius/openai/openai"]),
|
||||
("langgenius/openai/openai", ["langgenius/openai/openai", "openai"]),
|
||||
("langgenius/gemini/google", ["langgenius/gemini/google", "google"]),
|
||||
],
|
||||
)
|
||||
def test_get_provider_names_returns_short_and_full_aliases(provider_name: str, expected_provider_names: list[str]):
|
||||
assert ProviderManager._get_provider_names(provider_name) == expected_provider_names
|
||||
|
||||
|
||||
def test_get_provider_model_bundle_raises_for_unknown_provider():
|
||||
manager = ProviderManager()
|
||||
|
||||
with patch.object(manager, "get_configurations", return_value={}):
|
||||
with pytest.raises(ValueError, match="Provider openai does not exist."):
|
||||
manager.get_provider_model_bundle("tenant-id", "openai", ModelType.LLM)
|
||||
|
||||
|
||||
def test_get_configurations_builds_provider_configuration(
|
||||
mock_provider_entity,
|
||||
):
|
||||
manager = ProviderManager()
|
||||
provider_configuration = Mock()
|
||||
provider_factory = Mock()
|
||||
provider_factory.get_providers.return_value = [mock_provider_entity]
|
||||
custom_configuration = SimpleNamespace(provider=None, models=[])
|
||||
system_configuration = SimpleNamespace(enabled=False, quota_configurations=[], current_quota_type=None)
|
||||
|
||||
with (
|
||||
patch.object(manager, "_get_all_providers", return_value={"openai": []}),
|
||||
patch.object(manager, "_init_trial_provider_records", return_value={"openai": []}),
|
||||
patch.object(manager, "_get_all_provider_models", return_value={"openai": []}),
|
||||
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, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory),
|
||||
patch("core.provider_manager.ProviderConfiguration", return_value=provider_configuration) as mock_pc,
|
||||
):
|
||||
manager.get_configurations("tenant-id")
|
||||
|
||||
mock_pc.assert_called_once()
|
||||
call_kw = mock_pc.call_args.kwargs
|
||||
assert call_kw["tenant_id"] == "tenant-id"
|
||||
assert call_kw["provider"] is mock_provider_entity
|
||||
|
||||
|
||||
def test_get_configurations_reuses_cached_result_for_same_tenant(mock_provider_entity):
|
||||
manager = ProviderManager()
|
||||
provider_configuration = Mock()
|
||||
provider_factory = Mock()
|
||||
provider_factory.get_providers.return_value = [mock_provider_entity]
|
||||
custom_configuration = SimpleNamespace(provider=None, models=[])
|
||||
system_configuration = SimpleNamespace(enabled=False, quota_configurations=[], current_quota_type=None)
|
||||
|
||||
with (
|
||||
patch.object(manager, "_get_all_providers", return_value={"openai": []}) as mock_get_all_providers,
|
||||
patch.object(manager, "_init_trial_provider_records", return_value={"openai": []}),
|
||||
patch.object(manager, "_get_all_provider_models", return_value={"openai": []}),
|
||||
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, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory) as mock_factory_cls,
|
||||
patch(
|
||||
"core.provider_manager.ProviderConfiguration",
|
||||
return_value=provider_configuration,
|
||||
) as mock_provider_configuration,
|
||||
):
|
||||
first = manager.get_configurations("tenant-id")
|
||||
second = manager.get_configurations("tenant-id")
|
||||
|
||||
assert first is second
|
||||
mock_get_all_providers.assert_called_once_with("tenant-id")
|
||||
mock_factory_cls.assert_called_once_with("tenant-id")
|
||||
mock_provider_configuration.assert_called_once()
|
||||
|
||||
|
||||
def test_clear_configurations_cache_rebuilds_requested_tenant(mock_provider_entity):
|
||||
manager = ProviderManager()
|
||||
provider_factory = Mock()
|
||||
provider_factory.get_providers.return_value = [mock_provider_entity]
|
||||
custom_configuration = SimpleNamespace(provider=None, models=[])
|
||||
system_configuration = SimpleNamespace(enabled=False, quota_configurations=[], current_quota_type=None)
|
||||
provider_configuration_first = Mock()
|
||||
provider_configuration_second = Mock()
|
||||
|
||||
with (
|
||||
patch.object(manager, "_get_all_providers", return_value={"openai": []}) as mock_get_all_providers,
|
||||
patch.object(manager, "_init_trial_provider_records", return_value={"openai": []}),
|
||||
patch.object(manager, "_get_all_provider_models", return_value={"openai": []}),
|
||||
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, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory),
|
||||
patch(
|
||||
"core.provider_manager.ProviderConfiguration",
|
||||
side_effect=[provider_configuration_first, provider_configuration_second],
|
||||
) as mock_provider_configuration,
|
||||
):
|
||||
first = manager.get_configurations("tenant-id")
|
||||
manager.clear_configurations_cache("tenant-id")
|
||||
second = manager.get_configurations("tenant-id")
|
||||
|
||||
assert first is not second
|
||||
assert mock_get_all_providers.call_count == 2
|
||||
assert mock_provider_configuration.call_count == 2
|
||||
|
||||
|
||||
def test_get_provider_model_bundle_returns_selected_model_type_instance():
|
||||
manager = ProviderManager()
|
||||
provider_configuration = Mock()
|
||||
model_type_instance = Mock()
|
||||
provider_configuration.get_model_type_instance.return_value = model_type_instance
|
||||
expected_bundle = Mock()
|
||||
|
||||
with (
|
||||
patch.object(manager, "get_configurations", return_value={"openai": provider_configuration}),
|
||||
patch("core.provider_manager.ProviderModelBundle", return_value=expected_bundle) as mock_bundle,
|
||||
):
|
||||
result = manager.get_provider_model_bundle("tenant-id", "openai", ModelType.LLM)
|
||||
|
||||
provider_configuration.get_model_type_instance.assert_called_once_with(ModelType.LLM)
|
||||
mock_bundle.assert_called_once_with(
|
||||
configuration=provider_configuration,
|
||||
model_type_instance=model_type_instance,
|
||||
)
|
||||
assert result is expected_bundle
|
||||
|
||||
|
||||
def test_get_first_provider_first_model_returns_none_when_no_models():
|
||||
manager = ProviderManager()
|
||||
provider_configurations = Mock()
|
||||
provider_configurations.get_models.return_value = []
|
||||
|
||||
with patch.object(manager, "get_configurations", return_value=provider_configurations):
|
||||
result = manager.get_first_provider_first_model("tenant-id", ModelType.LLM)
|
||||
|
||||
assert result == (None, None)
|
||||
provider_configurations.get_models.assert_called_once_with(model_type=ModelType.LLM, only_active=False)
|
||||
|
||||
|
||||
def test_get_first_provider_first_model_returns_first_model_and_provider():
|
||||
manager = ProviderManager()
|
||||
provider_configurations = Mock()
|
||||
provider_configurations.get_models.return_value = [
|
||||
Mock(model="gpt-4", provider=Mock(provider="openai")),
|
||||
Mock(model="gpt-4o", provider=Mock(provider="openai")),
|
||||
]
|
||||
|
||||
with patch.object(manager, "get_configurations", return_value=provider_configurations):
|
||||
result = manager.get_first_provider_first_model("tenant-id", ModelType.LLM)
|
||||
|
||||
assert result == ("openai", "gpt-4")
|
||||
|
||||
|
||||
def test_update_default_model_record_raises_for_unknown_provider():
|
||||
manager = ProviderManager()
|
||||
|
||||
with patch.object(manager, "get_configurations", return_value={}):
|
||||
with pytest.raises(ValueError, match="Provider openai does not exist."):
|
||||
manager.update_default_model_record("tenant-id", ModelType.LLM, "openai", "gpt-4")
|
||||
|
||||
|
||||
def test_update_default_model_record_raises_for_unknown_model():
|
||||
manager = ProviderManager()
|
||||
provider_configurations = MagicMock()
|
||||
provider_configurations.__contains__.return_value = True
|
||||
provider_configurations.get_models.return_value = [Mock(model="gpt-4")]
|
||||
|
||||
with patch.object(manager, "get_configurations", return_value=provider_configurations):
|
||||
with pytest.raises(ValueError, match="Model gpt-3.5-turbo does not exist."):
|
||||
manager.update_default_model_record("tenant-id", ModelType.LLM, "openai", "gpt-3.5-turbo")
|
||||
|
||||
provider_configurations.get_models.assert_called_once_with(model_type=ModelType.LLM, only_active=True)
|
||||
|
||||
|
||||
def test_update_default_model_record_updates_existing_record():
|
||||
manager = ProviderManager()
|
||||
provider_configurations = MagicMock()
|
||||
provider_configurations.__contains__.return_value = True
|
||||
provider_configurations.get_models.return_value = [Mock(model="gpt-3.5-turbo")]
|
||||
existing_default_model = TenantDefaultModel(
|
||||
tenant_id="tenant-id",
|
||||
provider_name="anthropic",
|
||||
model_name="claude-3-sonnet",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
mock_session = Mock()
|
||||
mock_session.scalar.return_value = existing_default_model
|
||||
|
||||
with (
|
||||
patch.object(manager, "get_configurations", return_value=provider_configurations),
|
||||
patch("core.provider_manager.db.session", mock_session),
|
||||
):
|
||||
result = manager.update_default_model_record("tenant-id", ModelType.LLM, "openai", "gpt-3.5-turbo")
|
||||
|
||||
assert result is existing_default_model
|
||||
assert existing_default_model.provider_name == "openai"
|
||||
assert existing_default_model.model_name == "gpt-3.5-turbo"
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_session.add.assert_not_called()
|
||||
|
||||
|
||||
def test_update_default_model_record_creates_new_record_stores_str_model_type_value():
|
||||
manager = ProviderManager()
|
||||
provider_configurations = MagicMock()
|
||||
provider_configurations.__contains__.return_value = True
|
||||
provider_configurations.get_models.return_value = [Mock(model="gpt-4")]
|
||||
mock_session = Mock()
|
||||
mock_session.scalar.return_value = None
|
||||
|
||||
with (
|
||||
patch.object(manager, "get_configurations", return_value=provider_configurations),
|
||||
patch("core.provider_manager.db.session", mock_session),
|
||||
):
|
||||
result = manager.update_default_model_record("tenant-id", ModelType.LLM, "openai", "gpt-4")
|
||||
|
||||
mock_session.add.assert_called_once()
|
||||
created_default_model = mock_session.add.call_args.args[0]
|
||||
assert result is created_default_model
|
||||
assert created_default_model.tenant_id == "tenant-id"
|
||||
assert created_default_model.provider_name == "openai"
|
||||
assert created_default_model.model_name == "gpt-4"
|
||||
# ProviderManager persists ``ModelType`` string value in DB, not the origin Dify key.
|
||||
assert created_default_model.model_type == ModelType.LLM.value
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_get_all_providers_normalizes_provider_names_with_model_provider_id() -> None:
|
||||
session = Mock()
|
||||
openai_provider = SimpleNamespace(provider_name="openai")
|
||||
gemini_provider = SimpleNamespace(provider_name="langgenius/gemini/google")
|
||||
session.scalars.return_value = [openai_provider, gemini_provider]
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.db", SimpleNamespace(engine=object())),
|
||||
patch("core.provider_manager.Session", return_value=_build_session_context(session)),
|
||||
):
|
||||
result = ProviderManager._get_all_providers("tenant-id")
|
||||
|
||||
assert list(result[str(ModelProviderID("openai"))]) == [openai_provider]
|
||||
assert list(result[str(ModelProviderID("langgenius/gemini/google"))]) == [gemini_provider]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method_name",
|
||||
[
|
||||
"_get_all_provider_models",
|
||||
"_get_all_provider_model_settings",
|
||||
"_get_all_provider_model_credentials",
|
||||
],
|
||||
)
|
||||
def test_provider_grouping_helpers_group_records_by_provider_name(method_name: str) -> None:
|
||||
session = Mock()
|
||||
openai_primary = SimpleNamespace(provider_name="openai")
|
||||
openai_secondary = SimpleNamespace(provider_name="openai")
|
||||
anthropic_record = SimpleNamespace(provider_name="anthropic")
|
||||
session.scalars.return_value = [openai_primary, openai_secondary, anthropic_record]
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.db", SimpleNamespace(engine=object())),
|
||||
patch("core.provider_manager.Session", return_value=_build_session_context(session)),
|
||||
):
|
||||
result = getattr(ProviderManager, method_name)("tenant-id")
|
||||
|
||||
assert list(result["openai"]) == [openai_primary, openai_secondary]
|
||||
assert list(result["anthropic"]) == [anthropic_record]
|
||||
|
||||
|
||||
def test_get_all_preferred_model_providers_returns_mapping_by_provider_name() -> None:
|
||||
session = Mock()
|
||||
openai_preference = SimpleNamespace(provider_name="openai")
|
||||
anthropic_preference = SimpleNamespace(provider_name="anthropic")
|
||||
session.scalars.return_value = [openai_preference, anthropic_preference]
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.db", SimpleNamespace(engine=object())),
|
||||
patch("core.provider_manager.Session", return_value=_build_session_context(session)),
|
||||
):
|
||||
result = ProviderManager._get_all_preferred_model_providers("tenant-id")
|
||||
|
||||
assert result == {
|
||||
"openai": openai_preference,
|
||||
"anthropic": anthropic_preference,
|
||||
}
|
||||
|
||||
|
||||
def test_get_all_provider_load_balancing_configs_returns_empty_when_cached_flag_is_disabled() -> None:
|
||||
with (
|
||||
patch("core.provider_manager.redis_client.get", return_value=b"False"),
|
||||
patch("core.provider_manager.FeatureService.get_features") as mock_get_features,
|
||||
patch("core.provider_manager.Session") as mock_session_cls,
|
||||
):
|
||||
result = ProviderManager._get_all_provider_load_balancing_configs("tenant-id")
|
||||
|
||||
assert result == {}
|
||||
mock_get_features.assert_not_called()
|
||||
mock_session_cls.assert_not_called()
|
||||
|
||||
|
||||
def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_configs() -> None:
|
||||
session = Mock()
|
||||
openai_config = SimpleNamespace(provider_name="openai")
|
||||
anthropic_config = SimpleNamespace(provider_name="anthropic")
|
||||
session.scalars.return_value = [openai_config, anthropic_config]
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.db", SimpleNamespace(engine=object())),
|
||||
patch("core.provider_manager.redis_client.get", return_value=None),
|
||||
patch("core.provider_manager.redis_client.setex") as mock_setex,
|
||||
patch(
|
||||
"core.provider_manager.FeatureService.get_features",
|
||||
return_value=SimpleNamespace(model_load_balancing_enabled=True),
|
||||
),
|
||||
patch("core.provider_manager.Session", return_value=_build_session_context(session)),
|
||||
):
|
||||
result = ProviderManager._get_all_provider_load_balancing_configs("tenant-id")
|
||||
|
||||
mock_setex.assert_called_once_with("tenant:tenant-id:model_load_balancing_enabled", 120, "True")
|
||||
assert list(result["openai"]) == [openai_config]
|
||||
assert list(result["anthropic"]) == [anthropic_config]
|
||||
|
||||
16
api/tests/unit_tests/events/test_opensearch_import.py
Normal file
16
api/tests/unit_tests/events/test_opensearch_import.py
Normal file
@ -0,0 +1,16 @@
|
||||
def test_local_events_exports_compat_events_class():
|
||||
import events
|
||||
|
||||
evt = events.Events()
|
||||
called = []
|
||||
|
||||
evt.request_start += lambda *args, **kwargs: called.append((args, kwargs))
|
||||
evt.request_start("GET", "/_search")
|
||||
|
||||
assert len(called) == 1
|
||||
|
||||
|
||||
def test_opensearch_import_works_with_local_events_package():
|
||||
from opensearchpy import OpenSearch
|
||||
|
||||
assert OpenSearch is not None
|
||||
@ -189,6 +189,27 @@ class TestSetDefaultProvider:
|
||||
assert target.is_default is True
|
||||
session.commit.assert_called_once()
|
||||
|
||||
@patch(f"{MODULE}.Session")
|
||||
@patch(f"{MODULE}.db")
|
||||
def test_clear_default_is_tenant_scoped_not_user_scoped(self, mock_db, mock_session_cls):
|
||||
# Regression: clearing prior defaults must NOT filter by user_id, otherwise
|
||||
# two workspace members can each leave their own credential as default at
|
||||
# the same time (the default flag is tenant-scoped, not per-user).
|
||||
session = _mock_session(mock_session_cls)
|
||||
session.query.return_value.filter_by.return_value.first.return_value = MagicMock()
|
||||
|
||||
BuiltinToolManageService.set_default_provider("tenant-1", "user-A", "google", "cred-id")
|
||||
|
||||
clear_calls = [
|
||||
call
|
||||
for call in session.query.return_value.filter_by.call_args_list
|
||||
if call.kwargs.get("is_default") is True
|
||||
]
|
||||
assert len(clear_calls) == 1
|
||||
assert "user_id" not in clear_calls[0].kwargs
|
||||
assert clear_calls[0].kwargs["tenant_id"] == "tenant-1"
|
||||
assert clear_calls[0].kwargs["provider"] == "google"
|
||||
|
||||
|
||||
class TestUpdateBuiltinToolProvider:
|
||||
@patch(f"{MODULE}.Session")
|
||||
|
||||
1211
api/uv.lock
generated
1211
api/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@ -125,15 +125,15 @@
|
||||
"mime": "4.1.0",
|
||||
"mitt": "3.0.1",
|
||||
"negotiator": "1.0.0",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"next-themes": "0.4.6",
|
||||
"nuqs": "2.8.9",
|
||||
"pinyin-pro": "3.28.0",
|
||||
"qrcode.react": "4.2.0",
|
||||
"qs": "6.15.0",
|
||||
"react": "19.2.4",
|
||||
"react": "19.2.5",
|
||||
"react-18-input-autosize": "3.0.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dom": "19.2.5",
|
||||
"react-easy-crop": "5.5.6",
|
||||
"react-hotkeys-hook": "5.2.4",
|
||||
"react-i18next": "16.6.1",
|
||||
@ -173,8 +173,8 @@
|
||||
"@mdx-js/loader": "3.1.1",
|
||||
"@mdx-js/react": "3.1.1",
|
||||
"@mdx-js/rollup": "3.1.1",
|
||||
"@next/eslint-plugin-next": "16.2.1",
|
||||
"@next/mdx": "16.2.1",
|
||||
"@next/eslint-plugin-next": "16.2.3",
|
||||
"@next/mdx": "16.2.3",
|
||||
"@rgrove/parse-xml": "4.2.0",
|
||||
"@storybook/addon-docs": "10.3.1",
|
||||
"@storybook/addon-links": "10.3.1",
|
||||
@ -231,7 +231,7 @@
|
||||
"nock": "14.0.11",
|
||||
"postcss": "8.5.8",
|
||||
"postcss-js": "5.1.0",
|
||||
"react-server-dom-webpack": "19.2.4",
|
||||
"react-server-dom-webpack": "19.2.5",
|
||||
"sass": "1.98.0",
|
||||
"storybook": "10.3.1",
|
||||
"tailwindcss": "3.4.19",
|
||||
@ -251,6 +251,7 @@
|
||||
"@lexical/code": "npm:lexical-code-no-prism@0.41.0",
|
||||
"@monaco-editor/loader": "1.7.0",
|
||||
"@nolyfill/safe-buffer": "npm:safe-buffer@^5.2.1",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"array-includes": "npm:@nolyfill/array-includes@^1.0.44",
|
||||
"array.prototype.findlast": "npm:@nolyfill/array.prototype.findlast@^1.0.44",
|
||||
"array.prototype.findlastindex": "npm:@nolyfill/array.prototype.findlastindex@^1.0.44",
|
||||
@ -258,9 +259,11 @@
|
||||
"array.prototype.flatmap": "npm:@nolyfill/array.prototype.flatmap@^1.0.44",
|
||||
"array.prototype.tosorted": "npm:@nolyfill/array.prototype.tosorted@^1.0.44",
|
||||
"assert": "npm:@nolyfill/assert@^1.0.26",
|
||||
"brace-expansion@<2.0.2": "2.0.2",
|
||||
"brace-expansion@<2.0.3": "2.0.3",
|
||||
"brace-expansion@>=5.0.0 <5.0.5": "5.0.5",
|
||||
"canvas": "^3.2.2",
|
||||
"devalue@<5.3.2": "5.3.2",
|
||||
"diff@>=5.0.0 <5.2.2": "5.2.2",
|
||||
"dompurify@>=3.1.3 <=3.3.1": "3.3.2",
|
||||
"es-iterator-helpers": "npm:@nolyfill/es-iterator-helpers@^1.0.21",
|
||||
"esbuild@<0.27.2": "0.27.2",
|
||||
@ -271,6 +274,7 @@
|
||||
"is-generator-function": "npm:@nolyfill/is-generator-function@^1.0.44",
|
||||
"is-typed-array": "npm:@nolyfill/is-typed-array@^1.0.44",
|
||||
"isarray": "npm:@nolyfill/isarray@^1.0.44",
|
||||
"minimatch@>=9.0.0 <10.0.0": "10.2.4",
|
||||
"object.assign": "npm:@nolyfill/object.assign@^1.0.44",
|
||||
"object.entries": "npm:@nolyfill/object.entries@^1.0.44",
|
||||
"object.fromentries": "npm:@nolyfill/object.fromentries@^1.0.44",
|
||||
@ -278,6 +282,9 @@
|
||||
"object.values": "npm:@nolyfill/object.values@^1.0.44",
|
||||
"pbkdf2": "~3.1.5",
|
||||
"pbkdf2@<3.1.3": "3.1.3",
|
||||
"picomatch@>=2.0.0 <2.3.2": "2.3.2",
|
||||
"picomatch@>=3.0.0 <3.0.2": "3.0.2",
|
||||
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
||||
"prismjs": "~1.30",
|
||||
"prismjs@<1.30.0": "1.30.0",
|
||||
"rollup@>=4.0.0 <4.59.0": "4.59.0",
|
||||
@ -298,6 +305,7 @@
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@0.1.13",
|
||||
"vitest": "npm:@voidzero-dev/vite-plus-test@0.1.13",
|
||||
"which-typed-array": "npm:@nolyfill/which-typed-array@^1.0.44",
|
||||
"xmldom": "npm:@xmldom/xmldom@0.8.13",
|
||||
"yauzl@<3.2.1": "3.2.1"
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
|
||||
1136
web/pnpm-lock.yaml
generated
1136
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user