feat: optimize integrations initial loading (#40118)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Jingyi
2026-08-07 02:06:25 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 6f2e4d72d3
commit 5dfd83e9ab
126 changed files with 6492 additions and 1611 deletions
@@ -4,9 +4,11 @@ from typing import Any, Literal
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from controllers.common.fields import SimpleResultResponse, ValidationResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
@@ -26,8 +28,13 @@ from libs.helper import dump_response, uuid_value
from libs.login import login_required
from models import Account
from services.billing_service import BillingService
from services.entities.model_provider_entities import ProviderResponse
from services.entities.model_provider_entities import (
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
ProviderResponse,
)
from services.model_provider_service import ModelProviderService
from services.workspace_service import WorkspaceService
class ParserModelList(BaseModel):
@@ -91,6 +98,22 @@ class ModelProviderListResponse(ResponseModel):
data: list[ProviderResponse]
class ModelProviderSummaryListResponse(ResponseModel):
data: list[ModelProviderSummaryResponse]
plugins: dict[str, ModelProviderPluginSummaryResponse]
class ModelProviderCreditsResponse(ResponseModel):
pool_type: Literal["paid", "trial"] | None
quota_limit: int | None = Field(description="Credit limit for the effective pool; -1 means unlimited.")
quota_used: int | None
remaining_credits: int | None = Field(description="Remaining credits; -1 means unlimited.")
is_unlimited: bool
is_exhausted: bool
exhausted_at: int | None
next_credit_reset_date: int | None
class ProviderCredentialsResponse(ResponseModel):
credentials: dict[str, Any] | None = None
@@ -114,6 +137,8 @@ register_response_schema_models(
console_ns,
SimpleResultResponse,
ModelProviderListResponse,
ModelProviderSummaryListResponse,
ModelProviderCreditsResponse,
ProviderCredentialsResponse,
ValidationResultResponse,
ModelProviderPaymentCheckoutUrlResponse,
@@ -140,6 +165,40 @@ class ModelProviderListApi(Resource):
return ModelProviderListResponse(data=provider_list).model_dump(mode="json")
@console_ns.route("/workspaces/current/model-providers/summary")
class ModelProviderSummaryListApi(Resource):
@console_ns.response(
200,
"Model provider summaries retrieved successfully",
console_ns.models[ModelProviderSummaryListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
providers, plugins = ModelProviderService().get_provider_summary_list(tenant_id=tenant_id)
return dump_response(
ModelProviderSummaryListResponse,
{"data": providers, "plugins": plugins},
)
@console_ns.route("/workspaces/current/model-providers/credits")
class ModelProviderCreditsApi(Resource):
@console_ns.response(
200, "Model provider credits retrieved successfully", console_ns.models[ModelProviderCreditsResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str):
credit_pool = WorkspaceService.get_effective_credit_pool(tenant_id, session=session)
return dump_response(ModelProviderCreditsResponse, credit_pool)
@console_ns.route("/workspaces/current/model-providers/<path:provider>/credentials")
class ModelProviderCredentialApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserCredentialId))
+96 -5
View File
@@ -1,5 +1,5 @@
import io
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any, Literal, TypedDict
@@ -43,6 +43,7 @@ from core.plugin.entities.plugin_daemon import PluginDecodeResponse, PluginInsta
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.plugin_service import PluginService
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
from core.tools.entities.api_entities import ToolProviderApiEntity
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.tool_manager import ToolManager
@@ -90,9 +91,21 @@ class ParserList(BaseModel):
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
type PluginCategoryListLanguage = Literal["en_US", "zh_Hans", "ja_JP", "pt_BR"]
class PluginCategoryListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
query: str = Field(default="", max_length=256, description="Case-insensitive search query")
tags: list[str] = Field(default_factory=list, max_length=128, description="Match any plugin tag")
language: Literal["en_US", "zh_Hans", "ja_JP", "pt_BR"] = Field(
default="en_US", description="Language used for localized label and description search"
)
class PluginInstalledIdsQuery(BaseModel):
category: PluginCategory = Field(description="Plugin category to include")
class ParserLatest(BaseModel):
@@ -326,6 +339,10 @@ class PluginListResponse(ResponseModel):
total: int
class PluginInstalledIdsResponse(ResponseModel):
plugin_ids: list[str]
class PluginVersionsResponse(ResponseModel):
versions: Mapping[str, PluginService.LatestPluginCache | None]
@@ -385,6 +402,7 @@ register_schema_models(
console_ns,
ParserList,
PluginCategoryListQuery,
PluginInstalledIdsQuery,
PluginAutoUpgradeSettingsPayload,
PluginPermissionSettingsPayload,
ParserLatest,
@@ -421,6 +439,7 @@ register_response_schema_models(
PluginDebuggingKeyResponse,
PluginDynamicOptionsResponse,
PluginInstallationsResponse,
PluginInstalledIdsResponse,
PluginInstallTaskStartResponse,
PluginListResponse,
PluginManifestResponse,
@@ -478,7 +497,39 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
return content
def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]:
def _localized_builtin_tool_text(value: I18nObject, language: PluginCategoryListLanguage) -> str:
return value.to_dict()[language] or value.en_US
def _builtin_tool_provider_matches_filters(
provider: ToolProviderApiEntity,
*,
query: str,
tags: Sequence[str],
language: PluginCategoryListLanguage,
) -> bool:
if tags and not any(tag in provider.labels for tag in tags):
return False
if not query:
return True
lower_query = query.lower()
candidates = (
provider.name,
_localized_builtin_tool_text(provider.label, language),
_localized_builtin_tool_text(provider.description, language),
)
return any(lower_query in candidate.lower() for candidate in candidates)
def _list_hardcoded_builtin_tool_providers(
tenant_id: str,
*,
query: str = "",
tags: Sequence[str] = (),
language: PluginCategoryListLanguage = "en_US",
) -> list[dict[str, Any]]:
"""List builtin providers using the same search and tag semantics as category plugins."""
db_builtin_providers = {
str(ToolProviderID(provider.provider)): provider
for provider in ToolManager.list_default_builtin_providers(tenant_id)
@@ -499,6 +550,13 @@ def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any
db_provider=db_builtin_providers.get(provider.entity.identity.name),
decrypt_credentials=False,
)
if not _builtin_tool_provider_matches_filters(
user_provider,
query=query,
tags=tags,
language=language,
):
continue
ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider)
builtin_providers.append(user_provider)
@@ -553,7 +611,9 @@ class PluginCategoryListApi(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, category: str):
args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True))
args = PluginCategoryListQuery.model_validate(
{**request.args.to_dict(flat=True), "tags": request.args.getlist("tags")}
)
try:
plugin_category = PluginCategory(category)
@@ -561,13 +621,26 @@ class PluginCategoryListApi(Resource):
return {"code": "invalid_param", "message": "invalid plugin category"}, 400
try:
plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size)
plugins = PluginService.list_by_category(
tenant_id,
plugin_category,
args.page,
args.page_size,
query=args.query,
tags=args.tags,
language=args.language,
)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
builtin_tools = []
if plugin_category == PluginCategory.Tool:
builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id)
builtin_tools = _list_hardcoded_builtin_tool_providers(
tenant_id,
query=args.query,
tags=args.tags,
language=args.language,
)
return dump_response(
PluginCategoryListResponse,
@@ -579,6 +652,24 @@ class PluginCategoryListApi(Resource):
)
@console_ns.route("/workspaces/current/plugin/installed-ids")
class PluginInstalledIdsApi(Resource):
@console_ns.doc(params=query_params_from_model(PluginInstalledIdsQuery))
@console_ns.response(200, "Success", console_ns.models[PluginInstalledIdsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
args = PluginInstalledIdsQuery.model_validate(request.args.to_dict(flat=True))
try:
plugin_ids = PluginService.list_installed_plugin_ids(tenant_id, args.category)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
return dump_response(PluginInstalledIdsResponse, {"plugin_ids": plugin_ids})
@console_ns.route("/workspaces/current/plugin/list/latest-versions")
class PluginListLatestVersionsApi(Resource):
@console_ns.expect(console_ns.models[ParserLatest.__name__])
+17
View File
@@ -102,6 +102,19 @@ class PluginModelProviderEntity(BaseModel):
declaration: ProviderEntity = Field(description="The declaration of the model provider.")
class PluginModelProviderBinding(BaseModel):
"""Lightweight installation metadata for one model provider."""
provider: str
installation_id: str
plugin_id: str
plugin_unique_identifier: str
runtime_type: str
source: PluginInstallationSource
version: str
verified: bool = False
class PluginTextEmbeddingNumTokensResponse(BaseModel):
"""
Response for number of tokens.
@@ -215,6 +228,10 @@ class PluginListResponse(BaseModel):
total: int
class PluginInstalledIdsDaemonResponse(BaseModel):
plugin_ids: list[str]
class PluginListWithoutTotalResponse(BaseModel):
list: list[PluginEntity]
has_more: bool
+9
View File
@@ -6,6 +6,7 @@ from core.plugin.entities.plugin_daemon import (
PluginBasicBooleanResponse,
PluginDaemonInnerError,
PluginLLMNumTokensResponse,
PluginModelProviderBinding,
PluginModelProviderEntity,
PluginModelSchemaEntity,
PluginStringResultResponse,
@@ -48,6 +49,14 @@ class PluginModelClient(BasePluginClient):
)
return response
def fetch_model_provider_bindings(self, tenant_id: str) -> Sequence[PluginModelProviderBinding]:
"""Fetch only model-provider installation identities from the daemon."""
return self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/models/bindings",
list[PluginModelProviderBinding],
)
def get_model_schema(
self,
tenant_id: str,
+28 -2
View File
@@ -14,6 +14,7 @@ from core.plugin.entities.plugin import (
)
from core.plugin.entities.plugin_daemon import (
PluginDecodeResponse,
PluginInstalledIdsDaemonResponse,
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginListResponse,
@@ -68,6 +69,16 @@ class PluginInstaller(BasePluginClient):
)
return result.list
def list_installed_plugin_ids(self, tenant_id: str, category: PluginCategory) -> list[str]:
"""List all currently installed plugin IDs in one category."""
result = self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/installation/ids",
PluginInstalledIdsDaemonResponse,
params={"category": category.value},
)
return result.plugin_ids
def list_plugins_with_total(self, tenant_id: str, page: int, page_size: int) -> PluginListResponse:
return self._request_with_plugin_daemon_response(
"GET",
@@ -77,13 +88,28 @@ class PluginInstaller(BasePluginClient):
)
def list_plugins_by_category(
self, tenant_id: str, category: PluginCategory, page: int, page_size: int
self,
tenant_id: str,
category: PluginCategory,
page: int,
page_size: int,
*,
query: str = "",
tags: Sequence[str] = (),
language: str = "en_US",
) -> PluginListWithoutTotalResponse:
return self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/{category.value}/list",
PluginListWithoutTotalResponse,
params={"page": page, "page_size": page_size, "response_type": "paged"},
params={
"page": page,
"page_size": page_size,
"response_type": "paged",
"query": query,
"tags": list(tags),
"language": language,
},
)
def upload_pkg(
+50 -7
View File
@@ -48,6 +48,7 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTaskStatus,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginModelProviderBinding,
PluginModelProviderDeclaration,
PluginModelProviderEntity,
PluginVerification,
@@ -81,6 +82,12 @@ class _RedisLock(Protocol):
def release(self) -> None: ...
class _ModelPluginIdentity(Protocol):
plugin_id: str
plugin_unique_identifier: str
source: PluginInstallationSource
class PluginService:
class LatestPluginCache(BaseModel):
plugin_id: str
@@ -301,7 +308,7 @@ class PluginService:
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
@classmethod
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[PluginEntity]) -> str | None:
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[_ModelPluginIdentity]) -> str | None:
remote_model_plugins = sorted(
f"{plugin.plugin_id}:{plugin.plugin_unique_identifier}"
for plugin in plugins
@@ -386,7 +393,7 @@ class PluginService:
def _should_invalidate_model_provider_cache_for_remote_model_plugins(
cls,
tenant_id: str,
plugins: Sequence[PluginEntity],
plugins: Sequence[_ModelPluginIdentity],
) -> bool:
remote_model_plugin_marker = cls._get_remote_model_plugin_cache_marker(plugins)
cached_remote_model_plugin_marker = cls._load_cached_remote_model_plugin_marker(tenant_id)
@@ -715,6 +722,26 @@ class PluginService:
plugins = manager.list_plugins(tenant_id)
return plugins
@staticmethod
def list_installed_plugin_ids(tenant_id: str, category: PluginCategory) -> Sequence[str]:
"""List all currently installed plugin IDs in one category through the daemon's lightweight query."""
manager = PluginInstaller()
return manager.list_installed_plugin_ids(tenant_id, category)
@staticmethod
def list_model_provider_bindings(
tenant_id: str, *, client: PluginModelClient | None = None
) -> Sequence[PluginModelProviderBinding]:
"""Return fresh model bindings and reconcile remote-debug provider metadata before it is read."""
model_client = client or PluginModelClient()
bindings = model_client.fetch_model_provider_bindings(tenant_id)
if PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(tenant_id, bindings):
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
marker = PluginService._get_remote_model_plugin_cache_marker(bindings)
PluginService._store_cached_remote_model_plugin_marker(tenant_id, marker)
return bindings
@staticmethod
def list_with_total(tenant_id: str, user_id: str, page: int, page_size: int) -> PluginListResponse:
"""List tenant plugins with endpoint counts reconciled from live records.
@@ -731,17 +758,33 @@ class PluginService:
@staticmethod
def list_by_category(
tenant_id: str, category: PluginCategory, page: int, page_size: int
tenant_id: str,
category: PluginCategory,
page: int,
page_size: int,
*,
query: str = "",
tags: Sequence[str] = (),
language: str = "en_US",
) -> PluginListWithoutTotalResponse:
"""
List plugins in one category with a has-more cursor signal and without calculating total.
The daemon scans tenant installations in the existing list order and stops once it finds one extra match.
This keeps pagination usable before category is persisted on installation rows.
The daemon applies category, search, and tag filters before pagination, then stops once it finds one extra
match. Only a complete, unfiltered first page may reconcile the model-provider cache; the unpaginated model
binding read is the authoritative marker source for larger result sets.
"""
manager = PluginInstaller()
plugins = manager.list_plugins_by_category(tenant_id, category, page, page_size)
if category == PluginCategory.Model:
plugins = manager.list_plugins_by_category(
tenant_id,
category,
page,
page_size,
query=query,
tags=tags,
language=language,
)
if category == PluginCategory.Model and page == 1 and not plugins.has_more and not query and not tags:
should_invalidate_model_provider_cache = (
PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(
tenant_id,
+112
View File
@@ -10597,6 +10597,20 @@ Update a plugin endpoint
| ---- | ----------- | ------ |
| 200 | Model providers retrieved successfully | **application/json**: [ModelProviderListResponse](#modelproviderlistresponse)<br> |
### [GET] /workspaces/current/model-providers/credits
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Model provider credits retrieved successfully | **application/json**: [ModelProviderCreditsResponse](#modelprovidercreditsresponse)<br> |
### [GET] /workspaces/current/model-providers/summary
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Model provider summaries retrieved successfully | **application/json**: [ModelProviderSummaryListResponse](#modelprovidersummarylistresponse)<br> |
### [GET] /workspaces/current/model-providers/{provider}/checkout-url
#### Parameters
@@ -11142,6 +11156,19 @@ Returns permission flags that control workspace features like member invitations
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginInstallTaskStartResponse](#plugininstalltaskstartresponse)<br> |
### [GET] /workspaces/current/plugin/installed-ids
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| category | query | Plugin category to include | Yes | string, <br>**Available values:** "agent-strategy", "datasource", "extension", "model", "tool", "trigger" |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginInstalledIdsResponse](#plugininstalledidsresponse)<br> |
### [GET] /workspaces/current/plugin/list
#### Parameters
@@ -11400,8 +11427,11 @@ Returns permission flags that control workspace features like member invitations
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| language | query | Language used for localized label and description search | No | string, <br>**Available values:** "en_US", "ja_JP", "pt_BR", "zh_Hans", <br>**Default:** en_US |
| page | query | Page number | No | integer, <br>**Default:** 1 |
| page_size | query | Page size (1-256) | No | integer, <br>**Default:** 256 |
| query | query | Case-insensitive search query | No | string |
| tags | query | Match any plugin tag | No | [ string ] |
| category | path | | Yes | string |
#### Responses
@@ -19381,6 +19411,30 @@ Enum class for model property key.
| ---- | ---- | ----------- | -------- |
| ModelPropertyKey | string | Enum class for model property key. | |
#### ModelProviderCreditsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| exhausted_at | integer | | Yes |
| is_exhausted | boolean | | Yes |
| is_unlimited | boolean | | Yes |
| next_credit_reset_date | integer | | Yes |
| pool_type | string | | Yes |
| quota_limit | integer | Credit limit for the effective pool; -1 means unlimited. | Yes |
| quota_used | integer | | Yes |
| remaining_credits | integer | Remaining credits; -1 means unlimited. | Yes |
#### ModelProviderCustomConfigurationSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| available_credentials | [ [CredentialConfiguration](#credentialconfiguration) ] | | Yes |
| current_credential_id | string | | No |
| current_credential_name | string | | No |
| current_credential_usable | boolean | | Yes |
| has_custom_models | boolean | Whether custom model configuration exists, including saved model credentials. | Yes |
| status | [CustomConfigurationStatus](#customconfigurationstatus) | | Yes |
#### ModelProviderListResponse
| Name | Type | Description | Required |
@@ -19393,6 +19447,49 @@ Enum class for model property key.
| ---- | ---- | ----------- | -------- |
| payment_link | string | | Yes |
#### ModelProviderPluginSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| installation_id | string | | Yes |
| plugin_id | string | | Yes |
| plugin_unique_identifier | string | | Yes |
| runtime_type | string | | Yes |
| source | [PluginInstallationSource](#plugininstallationsource) | | Yes |
| version | string | | Yes |
#### ModelProviderSummaryListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [ModelProviderSummaryResponse](#modelprovidersummaryresponse) ] | | Yes |
| plugins | object | | Yes |
#### ModelProviderSummaryResponse
Fields required to render the collapsed model-provider list.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes |
| custom_configuration | [ModelProviderCustomConfigurationSummaryResponse](#modelprovidercustomconfigurationsummaryresponse) | | Yes |
| description | [I18nObject](#i18nobject) | | No |
| icon_small | [I18nObject](#i18nobject) | | No |
| icon_small_dark | [I18nObject](#i18nobject) | | No |
| is_configured | boolean | | Yes |
| label | [I18nObject](#i18nobject) | | Yes |
| plugin_id | string | | Yes |
| preferred_provider_type | [ProviderType](#providertype) | | Yes |
| provider | string | | Yes |
| supported_model_types | [ [ModelType](#modeltype) ] | | Yes |
| system_configuration | [ModelProviderSystemConfigurationSummaryResponse](#modelprovidersystemconfigurationsummaryresponse) | | Yes |
#### ModelProviderSystemConfigurationSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean | | Yes |
#### ModelSelectorScope
| Name | Type | Description | Required |
@@ -20382,8 +20479,11 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| language | string, <br>**Available values:** "en_US", "ja_JP", "pt_BR", "zh_Hans", <br>**Default:** en_US | Language used for localized label and description search<br>*Enum:* `"en_US"`, `"ja_JP"`, `"pt_BR"`, `"zh_Hans"` | No |
| page | integer, <br>**Default:** 1 | Page number | No |
| page_size | integer, <br>**Default:** 256 | Page size (1-256) | No |
| query | string | Case-insensitive search query | No |
| tags | [ string ] | Match any plugin tag | No |
#### PluginCategoryListResponse
@@ -20584,6 +20684,18 @@ Shared permission levels for resources (datasets, credentials, etc.)
| ---- | ---- | ----------- | -------- |
| plugins | [ [PluginInstallationItemResponse](#plugininstallationitemresponse) ] | | Yes |
#### PluginInstalledIdsQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| category | [PluginCategory](#plugincategory) | Plugin category to include | Yes |
#### PluginInstalledIdsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| plugin_ids | [ string ] | | Yes |
#### PluginListResponse
| Name | Type | Description | Required |
@@ -17,6 +17,7 @@ from core.entities.provider_entities import (
QuotaConfiguration,
UnaddedModelConfiguration,
)
from core.plugin.entities.plugin import PluginInstallationSource
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.model_entities import (
FetchFrom,
@@ -69,6 +70,67 @@ class SystemConfigurationResponse(BaseModel):
quota_configurations: list[QuotaConfiguration] = []
class ModelProviderCustomConfigurationSummaryResponse(BaseModel):
status: CustomConfigurationStatus
has_custom_models: bool = Field(
description="Whether custom model configuration exists, including saved model credentials."
)
available_credentials: list[CredentialConfiguration]
current_credential_id: str | None = None
current_credential_name: str | None = None
current_credential_usable: bool
class ModelProviderSystemConfigurationSummaryResponse(BaseModel):
enabled: bool
class ModelProviderPluginSummaryResponse(BaseModel):
installation_id: str
plugin_id: str
plugin_unique_identifier: str
runtime_type: str
source: PluginInstallationSource
version: str
class ModelProviderSummaryResponse(BaseModel):
"""Fields required to render the collapsed model-provider list."""
tenant_id: str = Field(exclude=True)
provider: str
plugin_id: str
label: I18nObject
description: I18nObject | None = None
icon_small: I18nObject | None = None
icon_small_dark: I18nObject | None = None
supported_model_types: Sequence[ModelType]
configurate_methods: list[ConfigurateMethod]
preferred_provider_type: ProviderType
is_configured: bool
custom_configuration: ModelProviderCustomConfigurationSummaryResponse
system_configuration: ModelProviderSystemConfigurationSummaryResponse
model_config = ConfigDict(protected_namespaces=())
@model_validator(mode="after")
def build_icon_urls(self):
url_prefix = (
dify_config.CONSOLE_API_URL + f"/console/api/workspaces/{self.tenant_id}/model-providers/{self.provider}"
)
if self.icon_small is not None:
self.icon_small = I18nObject(
en_US=f"{url_prefix}/icon_small/en_US",
zh_Hans=f"{url_prefix}/icon_small/zh_Hans",
)
if self.icon_small_dark is not None:
self.icon_small_dark = I18nObject(
en_US=f"{url_prefix}/icon_small_dark/en_US",
zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans",
)
return self
class ProviderResponse(BaseModel):
"""
Model class for provider response.
+279 -1
View File
@@ -1,18 +1,42 @@
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from sqlalchemy import and_, select
if TYPE_CHECKING:
from models.account import Account
from configs import dify_config
from core.db.session_factory import session_factory
from core.entities.model_entities import ModelWithProviderEntity, ProviderModelWithStatusEntity
from core.entities.provider_entities import CredentialConfiguration
from core.helper.position_helper import is_filtered
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginModelProviderBinding
from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory, create_plugin_provider_manager
from core.plugin.plugin_service import PluginService
from core.provider_manager import ProviderManager
from extensions import ext_hosting_provider
from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule
from models.provider import ProviderType
from models.provider import (
Provider,
ProviderCredential,
ProviderModel,
ProviderModelCredential,
ProviderType,
TenantPreferredModelProvider,
)
from models.provider_ids import ModelProviderID
from services.entities.model_provider_entities import (
CustomConfigurationResponse,
CustomConfigurationStatus,
DefaultModelResponse,
ModelProviderCustomConfigurationSummaryResponse,
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
ModelProviderSystemConfigurationSummaryResponse,
ModelWithProviderEntityResponse,
ProviderResponse,
ProviderWithModelsResponse,
@@ -24,6 +48,17 @@ from services.errors.app_model_config import ProviderNotFoundError
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class _ProviderSummaryState:
has_custom_provider: bool = False
available_credentials: list[CredentialConfiguration] = field(default_factory=list)
has_custom_models: bool = False
current_credential_id: str | None = None
current_credential_name: str | None = None
current_credential_usable: bool = False
preferred_provider_type: ProviderType | None = None
class ModelProviderService:
"""
Model Provider Service
@@ -132,6 +167,249 @@ class ModelProviderService:
return provider_responses
@staticmethod
def _load_provider_summary_states(tenant_id: str) -> dict[str, _ProviderSummaryState]:
"""Load only the workspace columns required by the collapsed provider list."""
with session_factory.create_session() as session:
custom_provider_rows = session.execute(
select(
Provider.provider_name,
Provider.credential_id,
ProviderCredential.provider_name.label("credential_provider_name"),
ProviderCredential.credential_name,
)
.outerjoin(
ProviderCredential,
and_(
ProviderCredential.id == Provider.credential_id,
ProviderCredential.tenant_id == tenant_id,
),
)
.where(
Provider.tenant_id == tenant_id,
Provider.provider_type == ProviderType.CUSTOM,
Provider.is_valid.is_(True),
)
).all()
credential_rows = session.execute(
select(
ProviderCredential.id,
ProviderCredential.provider_name,
ProviderCredential.credential_name,
)
.where(ProviderCredential.tenant_id == tenant_id)
.order_by(
ProviderCredential.created_at.desc(),
ProviderCredential.id.desc(),
)
).all()
custom_model_rows = session.execute(
select(ProviderModel.provider_name.label("provider_name"))
.where(
ProviderModel.tenant_id == tenant_id,
ProviderModel.is_valid.is_(True),
)
.union(
select(ProviderModelCredential.provider_name.label("provider_name")).where(
ProviderModelCredential.tenant_id == tenant_id
)
)
).all()
preferred_provider_rows = session.execute(
select(
TenantPreferredModelProvider.provider_name,
TenantPreferredModelProvider.preferred_provider_type,
).where(TenantPreferredModelProvider.tenant_id == tenant_id)
).all()
states: defaultdict[str, _ProviderSummaryState] = defaultdict(_ProviderSummaryState)
for credential in credential_rows:
provider_name = str(ModelProviderID(credential.provider_name))
states[provider_name].available_credentials.append(
CredentialConfiguration(
credential_id=credential.id,
credential_name=credential.credential_name,
)
)
selected_provider_priorities: dict[str, bool] = {}
for provider in custom_provider_rows:
provider_name = str(ModelProviderID(provider.provider_name))
state = states[provider_name]
state.has_custom_provider = True
is_canonical_row = provider.provider_name == provider_name
if provider_name in selected_provider_priorities and not is_canonical_row:
continue
selected_provider_priorities[provider_name] = is_canonical_row
state.current_credential_id = provider.credential_id
if (
provider.credential_provider_name is not None
and str(ModelProviderID(provider.credential_provider_name)) == provider_name
):
state.current_credential_name = provider.credential_name
state.current_credential_usable = True
else:
state.current_credential_name = None
state.current_credential_usable = False
for model in custom_model_rows:
states[str(ModelProviderID(model.provider_name))].has_custom_models = True
preferred_provider_priorities: dict[str, bool] = {}
for preferred_provider in preferred_provider_rows:
provider_name = str(ModelProviderID(preferred_provider.provider_name))
is_canonical_row = preferred_provider.provider_name == provider_name
if provider_name in preferred_provider_priorities and not is_canonical_row:
continue
preferred_provider_priorities[provider_name] = is_canonical_row
states[provider_name].preferred_provider_type = preferred_provider.preferred_provider_type
return dict(states)
@staticmethod
def _has_system_provider_hosting_configuration(provider: str) -> bool:
configuration = ext_hosting_provider.hosting_configuration.provider_map.get(provider)
return bool(configuration and configuration.enabled and configuration.quotas)
@staticmethod
def _select_binding(
current_binding: PluginModelProviderBinding | None,
candidate_binding: PluginModelProviderBinding,
) -> PluginModelProviderBinding:
"""Prefer a remote-debug runtime when one shadows an installed plugin."""
if current_binding is None:
return candidate_binding
if (
candidate_binding.source == PluginInstallationSource.Remote
and current_binding.source != PluginInstallationSource.Remote
):
return candidate_binding
return current_binding
@staticmethod
def _get_preferred_provider_type(
state: _ProviderSummaryState,
*,
custom_present: bool,
system_enabled: bool,
) -> ProviderType:
if state.preferred_provider_type is not None:
return state.preferred_provider_type
if dify_config.EDITION == "CLOUD" and system_enabled:
return ProviderType.SYSTEM
if custom_present:
return ProviderType.CUSTOM
if system_enabled:
return ProviderType.SYSTEM
return ProviderType.CUSTOM
def get_provider_summary_list(
self, tenant_id: str
) -> tuple[list[ModelProviderSummaryResponse], dict[str, ModelProviderPluginSummaryResponse]]:
"""Build the complete first-screen provider projection without assembling provider configurations."""
# Read bindings first: remote-debug identity changes invalidate provider metadata
# before the provider cache is consulted.
bindings = PluginService.list_model_provider_bindings(tenant_id)
provider_entities = PluginService.fetch_plugin_model_providers(tenant_id=tenant_id)
states = self._load_provider_summary_states(tenant_id)
bindings_by_provider: dict[str, PluginModelProviderBinding] = {}
for binding in bindings:
provider_name = (
str(ModelProviderID(binding.provider))
if binding.provider.count("/") == 2
else str(ModelProviderID(f"{binding.plugin_id}/{binding.provider}"))
)
bindings_by_provider[provider_name] = self._select_binding(
bindings_by_provider.get(provider_name),
binding,
)
provider_summaries: list[ModelProviderSummaryResponse] = []
emitted_provider_names: set[str] = set()
for provider_entity in provider_entities:
if is_filtered(
include_set=dify_config.POSITION_PROVIDER_INCLUDES_SET,
exclude_set=dify_config.POSITION_PROVIDER_EXCLUDES_SET,
data=provider_entity,
name_func=lambda provider: provider.provider,
):
continue
provider_id = ModelProviderID(provider_entity.provider)
provider_name = str(provider_id)
if provider_name in emitted_provider_names:
continue
emitted_provider_names.add(provider_name)
state = states.get(provider_name, _ProviderSummaryState())
custom_configured = (
state.has_custom_provider and bool(state.available_credentials)
) or state.has_custom_models
custom_present = state.has_custom_provider or state.has_custom_models
provider_binding = bindings_by_provider.get(provider_name)
system_enabled = bool(
provider_binding
and self._has_system_provider_hosting_configuration(provider_name)
and provider_binding.source != PluginInstallationSource.Package
and provider_binding.verified
)
preferred_provider_type = self._get_preferred_provider_type(
state,
custom_present=custom_present,
system_enabled=system_enabled,
)
provider_summaries.append(
ModelProviderSummaryResponse(
tenant_id=tenant_id,
provider=provider_name,
plugin_id=provider_id.plugin_id,
label=provider_entity.label,
description=provider_entity.description,
icon_small=provider_entity.icon_small,
icon_small_dark=provider_entity.icon_small_dark,
supported_model_types=provider_entity.supported_model_types,
configurate_methods=provider_entity.configurate_methods,
preferred_provider_type=preferred_provider_type,
is_configured=custom_configured or system_enabled,
custom_configuration=ModelProviderCustomConfigurationSummaryResponse(
status=CustomConfigurationStatus.ACTIVE
if custom_configured
else CustomConfigurationStatus.NO_CONFIGURE,
has_custom_models=state.has_custom_models,
available_credentials=state.available_credentials,
current_credential_id=state.current_credential_id,
current_credential_name=state.current_credential_name,
current_credential_usable=state.current_credential_usable,
),
system_configuration=ModelProviderSystemConfigurationSummaryResponse(
enabled=system_enabled,
),
)
)
plugin_bindings: dict[str, PluginModelProviderBinding] = {}
for binding in bindings_by_provider.values():
plugin_bindings[binding.plugin_id] = self._select_binding(
plugin_bindings.get(binding.plugin_id),
binding,
)
plugin_summaries = {
plugin_id: ModelProviderPluginSummaryResponse(
installation_id=binding.installation_id,
plugin_id=binding.plugin_id,
plugin_unique_identifier=binding.plugin_unique_identifier,
runtime_type=binding.runtime_type,
source=binding.source,
version=binding.version,
)
for plugin_id, binding in plugin_bindings.items()
}
return provider_summaries, plugin_summaries
def get_models_by_provider(self, tenant_id: str, provider: str) -> list[ModelWithProviderEntityResponse]:
"""
get provider models.
+76
View File
@@ -1,3 +1,6 @@
from dataclasses import dataclass
from typing import Literal
from flask_login import current_user
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -7,9 +10,37 @@ from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
from services.account_service import TenantService
from services.billing_service import BillingService
from services.feature_service import FeatureService
@dataclass(frozen=True)
class EffectiveCreditPool:
plan: str | None = None
pool_type: Literal["paid", "trial"] | None = None
quota_limit: int | None = None
quota_used: int | None = None
exhausted_at: int | None = None
next_credit_reset_date: int | None = None
@property
def remaining_credits(self) -> int | None:
if self.quota_limit is None or self.quota_used is None:
return None
if self.is_unlimited:
return -1
return max(0, self.quota_limit - self.quota_used)
@property
def is_unlimited(self) -> bool:
return self.quota_limit == -1
@property
def is_exhausted(self) -> bool:
remaining_credits = self.remaining_credits
return not self.is_unlimited and (remaining_credits is None or remaining_credits <= 0)
def _set_credit_pool_info(
tenant_info: dict[str, object], *, quota_limit: int, quota_used: int, exhausted_at: int | None = None
) -> None:
@@ -20,6 +51,51 @@ def _set_credit_pool_info(
class WorkspaceService:
@classmethod
def get_effective_credit_pool(cls, tenant_id: str, *, session: Session) -> EffectiveCreditPool:
if not dify_config.BILLING_ENABLED:
return EffectiveCreditPool()
billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True)
subscription_plan: str = billing_info["subscription"]["plan"]
from services.credit_pool_service import CreditPoolBalance, CreditPoolService
effective_pool = None
effective_pool_type: Literal["paid", "trial"] = "trial"
if subscription_plan != CloudPlan.SANDBOX:
paid_pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type="paid", session=session)
if paid_pool is not None and (paid_pool.quota_limit == -1 or paid_pool.quota_limit > paid_pool.quota_used):
effective_pool = paid_pool
effective_pool_type = "paid"
if effective_pool is None:
effective_pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type="trial", session=session)
if effective_pool is None:
return EffectiveCreditPool(
plan=subscription_plan if billing_info["enabled"] else None,
next_credit_reset_date=billing_info.get("next_credit_reset_date"),
)
exhausted_at = effective_pool.exhausted_at if isinstance(effective_pool, CreditPoolBalance) else None
if not (
isinstance(exhausted_at, int)
and exhausted_at > 0
and effective_pool.quota_limit > 0
and effective_pool.quota_used >= effective_pool.quota_limit
):
exhausted_at = None
return EffectiveCreditPool(
plan=subscription_plan if billing_info["enabled"] else None,
pool_type=effective_pool_type,
quota_limit=effective_pool.quota_limit,
quota_used=effective_pool.quota_used,
exhausted_at=exhausted_at,
next_credit_reset_date=billing_info.get("next_credit_reset_date"),
)
@classmethod
def get_tenant_info(cls, tenant: Tenant, session: Session):
if not tenant:
@@ -12,12 +12,15 @@ from configs import dify_config
from controllers.console.workspace.model_providers import (
ModelProviderCredentialApi,
ModelProviderCredentialSwitchApi,
ModelProviderCreditsApi,
ModelProviderIconApi,
ModelProviderListApi,
ModelProviderPaymentCheckoutUrlApi,
ModelProviderSummaryListApi,
ModelProviderValidateApi,
PreferredProviderTypeUpdateApi,
)
from core.entities.provider_entities import CredentialConfiguration
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
@@ -27,9 +30,14 @@ from models.provider import ProviderType
from services.entities.model_provider_entities import (
CustomConfigurationResponse,
CustomConfigurationStatus,
ModelProviderCustomConfigurationSummaryResponse,
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
ModelProviderSystemConfigurationSummaryResponse,
ProviderResponse,
SystemConfigurationResponse,
)
from services.workspace_service import EffectiveCreditPool
VALID_UUID = "123e4567-e89b-12d3-a456-426614174000"
INVALID_UUID = "123"
@@ -140,6 +148,136 @@ class TestModelProviderListApi:
assert result == {"data": []}
class TestModelProviderSummaryListApi:
def test_get_success(self, app: Flask):
api = ModelProviderSummaryListApi()
method = unwrap(api.get)
provider = ModelProviderSummaryResponse(
tenant_id="tenant1",
provider="langgenius/openai/openai",
plugin_id="langgenius/openai",
label=I18nObject(en_US="OpenAI"),
description=I18nObject(en_US="OpenAI models"),
icon_small=I18nObject(en_US="icon.svg"),
icon_small_dark=None,
supported_model_types=[ModelType.LLM],
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
preferred_provider_type=ProviderType.CUSTOM,
is_configured=True,
custom_configuration=ModelProviderCustomConfigurationSummaryResponse(
status=CustomConfigurationStatus.ACTIVE,
has_custom_models=True,
available_credentials=[
CredentialConfiguration(
credential_id=VALID_UUID,
credential_name="production",
),
CredentialConfiguration(
credential_id="223e4567-e89b-12d3-a456-426614174000",
credential_name="backup",
),
],
current_credential_id=VALID_UUID,
current_credential_name="production",
current_credential_usable=True,
),
system_configuration=ModelProviderSystemConfigurationSummaryResponse(enabled=False),
)
plugin = ModelProviderPluginSummaryResponse(
installation_id="installation-1",
plugin_id="langgenius/openai",
plugin_unique_identifier="langgenius/openai:1.0.0@checksum",
runtime_type="local",
source="marketplace",
version="1.0.0",
)
with (
app.test_request_context("/"),
patch(
"controllers.console.workspace.model_providers.ModelProviderService.get_provider_summary_list",
return_value=([provider], {"langgenius/openai": plugin}),
) as get_provider_summary_list,
):
result = method(api, "tenant1")
get_provider_summary_list.assert_called_once_with(tenant_id="tenant1")
assert result["data"][0]["provider"] == "langgenius/openai/openai"
assert "tenant_id" not in result["data"][0]
assert result["data"][0]["custom_configuration"] == {
"status": "active",
"has_custom_models": True,
"available_credentials": [
{
"credential_id": VALID_UUID,
"credential_name": "production",
},
{
"credential_id": "223e4567-e89b-12d3-a456-426614174000",
"credential_name": "backup",
},
],
"current_credential_id": VALID_UUID,
"current_credential_name": "production",
"current_credential_usable": True,
}
assert result["plugins"]["langgenius/openai"]["installation_id"] == "installation-1"
class TestModelProviderCreditsApi:
def test_get_success(self):
api = ModelProviderCreditsApi()
method = unwrap(api.get)
session = SimpleNamespace()
credit_pool = EffectiveCreditPool(
plan="team",
pool_type="paid",
quota_limit=-1,
quota_used=999,
next_credit_reset_date=1775001600,
)
with patch(
"controllers.console.workspace.model_providers.WorkspaceService.get_effective_credit_pool",
return_value=credit_pool,
) as get_effective_credit_pool:
result = method(api, session, "tenant1")
get_effective_credit_pool.assert_called_once_with("tenant1", session=session)
assert result == {
"pool_type": "paid",
"quota_limit": -1,
"quota_used": 999,
"remaining_credits": -1,
"is_unlimited": True,
"is_exhausted": False,
"exhausted_at": None,
"next_credit_reset_date": 1775001600,
}
def test_get_without_effective_pool(self):
api = ModelProviderCreditsApi()
method = unwrap(api.get)
session = SimpleNamespace()
with patch(
"controllers.console.workspace.model_providers.WorkspaceService.get_effective_credit_pool",
return_value=EffectiveCreditPool(),
):
result = method(api, session, "tenant1")
assert result == {
"pool_type": None,
"quota_limit": None,
"quota_used": None,
"remaining_credits": None,
"is_unlimited": False,
"is_exhausted": True,
"exhausted_at": None,
"next_credit_reset_date": None,
}
class TestModelProviderCredentialApi:
def test_get_success(self, app: Flask):
api = ModelProviderCredentialApi()
@@ -28,6 +28,7 @@ from controllers.console.workspace.plugin import (
PluginFetchMarketplacePkgApi,
PluginFetchPermissionApi,
PluginIconApi,
PluginInstalledIdsApi,
PluginInstallFromGithubApi,
PluginInstallFromMarketplaceApi,
PluginInstallFromPkgApi,
@@ -41,12 +42,16 @@ from controllers.console.workspace.plugin import (
PluginUploadFromBundleApi,
PluginUploadFromGithubApi,
PluginUploadFromPkgApi,
_list_hardcoded_builtin_tool_providers,
)
from core.plugin.entities.parameters import PluginParameterOption
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity, PluginInstallation
from core.plugin.entities.plugin import PluginCategory, PluginDeclaration, PluginEntity, PluginInstallation
from core.plugin.entities.plugin_daemon import PluginInstallTask
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.plugin_service import PluginService
from core.tools.entities.api_entities import ToolProviderApiEntity
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderType
from models.account import (
Account,
TenantAccountRole,
@@ -445,7 +450,7 @@ class TestPluginCategoryListApi:
mock_list = MagicMock(list=[plugin_item], has_more=True)
with (
app.test_request_context("/?page=2&page_size=10"),
app.test_request_context("/?page=2&page_size=10&query=weather&tags=search&tags=rag&language=zh_Hans"),
patch(
"controllers.console.workspace.plugin.PluginService.list_by_category", return_value=mock_list
) as list_mock,
@@ -456,18 +461,75 @@ class TestPluginCategoryListApi:
):
result = method(api, "t1", "tool")
list_mock.assert_called_once()
assert list_mock.call_args.args[0] == "t1"
assert list_mock.call_args.args[1] == "tool"
assert list_mock.call_args.args[2] == 2
assert list_mock.call_args.args[3] == 10
list_mock.assert_called_once_with(
"t1",
"tool",
2,
10,
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
assert result["plugins"][0]["id"] == "entity-1"
assert result["plugins"][0]["plugin_unique_identifier"] == "test-author/test-plugin:1.0.0@checksum"
assert result["builtin_tools"][0]["id"] == "builtin"
assert result["builtin_tools"][0]["type"] == "builtin"
assert result["has_more"] is True
assert "total" not in result
builtin_mock.assert_called_once_with("t1")
builtin_mock.assert_called_once_with(
"t1",
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
def test_builtin_tool_providers_use_the_category_list_filters(self):
search_provider = ToolProviderApiEntity(
id="search-provider",
author="dify",
name="search-provider",
description=I18nObject(en_US="Search provider", zh_Hans="搜索工具"),
icon="icon.svg",
label=I18nObject(en_US="Search", zh_Hans="搜索"),
type=ToolProviderType.BUILT_IN,
labels=["search"],
)
rag_provider = ToolProviderApiEntity(
id="rag-provider",
author="dify",
name="rag-provider",
description=I18nObject(en_US="RAG provider", zh_Hans="知识库工具"),
icon="icon.svg",
label=I18nObject(en_US="RAG", zh_Hans="知识库"),
type=ToolProviderType.BUILT_IN,
labels=["rag"],
)
with (
patch("controllers.console.workspace.plugin.ToolManager.list_default_builtin_providers", return_value=[]),
patch(
"controllers.console.workspace.plugin.ToolManager.list_hardcoded_providers",
return_value=[MagicMock(), MagicMock()],
),
patch("controllers.console.workspace.plugin.is_filtered", return_value=False),
patch(
"controllers.console.workspace.plugin.ToolTransformService.builtin_provider_to_user_provider",
side_effect=[search_provider, rag_provider],
),
patch("controllers.console.workspace.plugin.ToolTransformService.repack_provider"),
patch(
"controllers.console.workspace.plugin.BuiltinToolProviderSort.sort",
side_effect=lambda providers: providers,
),
):
result = _list_hardcoded_builtin_tool_providers(
"t1",
query="搜索",
tags=["search", "weather"],
language="zh_Hans",
)
assert [provider["id"] for provider in result] == ["search-provider"]
def test_non_tool_category_does_not_include_builtin_tools(self, app: Flask):
api = PluginCategoryListApi()
@@ -739,6 +801,39 @@ class TestPluginListInstallationsFromIdsApi:
assert result == ({"code": "plugin_error", "message": "error"}, 400)
class TestPluginInstalledIdsApi:
def test_success(self, app: Flask):
api = PluginInstalledIdsApi()
method = unwrap(api.get)
with (
app.test_request_context("/?category=tool"),
patch(
"controllers.console.workspace.plugin.PluginService.list_installed_plugin_ids",
return_value=["langgenius/openai", "langgenius/anthropic"],
) as list_installed_plugin_ids,
):
result = method(api, "t1")
assert result == {"plugin_ids": ["langgenius/openai", "langgenius/anthropic"]}
list_installed_plugin_ids.assert_called_once_with("t1", PluginCategory.Tool)
def test_daemon_error(self, app: Flask):
api = PluginInstalledIdsApi()
method = unwrap(api.get)
with (
app.test_request_context("/?category=tool"),
patch(
"controllers.console.workspace.plugin.PluginService.list_installed_plugin_ids",
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
class TestPluginUploadFromGithubApi:
def test_success(self, app: Flask, user):
api = PluginUploadFromGithubApi()
@@ -630,6 +630,11 @@ def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path)
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
operation = payload["paths"]["/workspaces/current/plugin/{category}/list"]["get"]
parameters = {parameter["name"]: parameter for parameter in operation["parameters"]}
assert parameters["query"]["in"] == "query"
assert parameters["tags"]["in"] == "query"
assert parameters["tags"]["schema"]["type"] == "array"
assert parameters["language"]["in"] == "query"
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
"#/components/schemas/"
)
@@ -657,3 +662,114 @@ def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path)
builtin_tool_schema = schemas["PluginCategoryBuiltinToolProviderResponse"]
for field in ("plugin_unique_identifier", "team_credentials", "type", "tools"):
assert field in builtin_tool_schema["properties"]
def test_console_installed_plugin_ids_exported_schema_is_lightweight(tmp_path):
from dev.generate_swagger_specs import generate_specs
written_paths = generate_specs(tmp_path)
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
operation = payload["paths"]["/workspaces/current/plugin/installed-ids"]["get"]
parameters = {parameter["name"]: parameter for parameter in operation["parameters"]}
assert parameters["category"]["in"] == "query"
assert parameters["category"]["required"] is True
assert parameters["category"]["schema"]["enum"] == [
"agent-strategy",
"datasource",
"extension",
"model",
"tool",
"trigger",
]
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
"#/components/schemas/"
)
response_schema = payload["components"]["schemas"][response_ref]
assert response_schema["required"] == ["plugin_ids"]
assert response_schema["properties"] == {
"plugin_ids": {
"items": {"type": "string"},
"title": "Plugin Ids",
"type": "array",
}
}
def test_console_model_provider_summary_exported_schema_is_lightweight(tmp_path):
from dev.generate_swagger_specs import generate_specs
written_paths = generate_specs(tmp_path)
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
operation = payload["paths"]["/workspaces/current/model-providers/summary"]["get"]
assert operation.get("parameters", []) == []
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
"#/components/schemas/"
)
response_schema = payload["components"]["schemas"][response_ref]
assert response_schema["required"] == ["data", "plugins"]
assert response_schema["properties"]["data"]["items"]["$ref"] == (
"#/components/schemas/ModelProviderSummaryResponse"
)
assert response_schema["properties"]["plugins"]["additionalProperties"]["$ref"] == (
"#/components/schemas/ModelProviderPluginSummaryResponse"
)
provider_properties = payload["components"]["schemas"]["ModelProviderSummaryResponse"]["properties"]
assert set(provider_properties) == {
"configurate_methods",
"custom_configuration",
"description",
"icon_small",
"icon_small_dark",
"is_configured",
"label",
"plugin_id",
"preferred_provider_type",
"provider",
"supported_model_types",
"system_configuration",
}
assert "provider_credential_schema" not in provider_properties
assert "model_credential_schema" not in provider_properties
custom_configuration_schema = payload["components"]["schemas"]["ModelProviderCustomConfigurationSummaryResponse"]
custom_configuration_properties = custom_configuration_schema["properties"]
assert set(custom_configuration_schema["required"]) == {
"available_credentials",
"current_credential_usable",
"has_custom_models",
"status",
}
assert set(custom_configuration_properties) == {
"available_credentials",
"current_credential_id",
"current_credential_name",
"current_credential_usable",
"has_custom_models",
"status",
}
assert custom_configuration_properties["available_credentials"]["items"]["$ref"] == (
"#/components/schemas/CredentialConfiguration"
)
assert "has_credentials" not in custom_configuration_properties
credential_properties = payload["components"]["schemas"]["CredentialConfiguration"]["properties"]
assert set(credential_properties) == {
"credential_id",
"credential_name",
}
assert "encrypted_config" not in credential_properties
plugin_properties = payload["components"]["schemas"]["ModelProviderPluginSummaryResponse"]["properties"]
assert set(plugin_properties) == {
"installation_id",
"plugin_id",
"plugin_unique_identifier",
"runtime_type",
"source",
"version",
}
@@ -28,6 +28,19 @@ class TestPluginModelClient:
)
assert request_mock.call_args.kwargs["params"] == {"page": 1, "page_size": 256}
def test_fetch_model_provider_bindings(self, mocker: MockerFixture):
client = PluginModelClient()
request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=["binding-a"])
result = client.fetch_model_provider_bindings("tenant-1")
assert result == ["binding-a"]
assert request_mock.call_args.args[:2] == (
"GET",
"plugin/tenant-1/management/models/bindings",
)
assert "params" not in request_mock.call_args.kwargs
def test_get_model_schema(self, mocker: MockerFixture):
client = PluginModelClient()
schema = SimpleNamespace(name="schema")
@@ -29,6 +29,7 @@ from core.plugin.entities.plugin import (
)
from core.plugin.entities.plugin_daemon import (
PluginDecodeResponse,
PluginInstalledIdsDaemonResponse,
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginInstallTaskStatus,
@@ -132,7 +133,13 @@ class TestPluginDiscovery:
plugin_installer, "_request_with_plugin_daemon_response", return_value=mock_response
) as mock_request:
result = plugin_installer.list_plugins_by_category(
"test-tenant", category=PluginCategory.Tool, page=2, page_size=10
"test-tenant",
category=PluginCategory.Tool,
page=2,
page_size=10,
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
mock_request.assert_called_once()
@@ -141,6 +148,9 @@ class TestPluginDiscovery:
assert call_args.args[2] is PluginListWithoutTotalResponse
assert call_args.kwargs["params"]["page"] == 2
assert call_args.kwargs["params"]["page_size"] == 10
assert call_args.kwargs["params"]["query"] == "weather"
assert call_args.kwargs["params"]["tags"] == ["search", "rag"]
assert call_args.kwargs["params"]["language"] == "zh_Hans"
assert result.list == [mock_plugin_entity]
assert result.has_more is True
@@ -156,6 +166,23 @@ class TestPluginDiscovery:
# Assert: Verify empty list is returned
assert len(result) == 0
def test_list_installed_plugin_ids(self, plugin_installer):
"""The lightweight ID endpoint is unpaginated and does not request plugin details."""
mock_response = PluginInstalledIdsDaemonResponse(plugin_ids=["langgenius/openai", "langgenius/anthropic"])
with patch.object(
plugin_installer, "_request_with_plugin_daemon_response", return_value=mock_response
) as mock_request:
result = plugin_installer.list_installed_plugin_ids("test-tenant", PluginCategory.Tool)
mock_request.assert_called_once_with(
"GET",
"plugin/test-tenant/management/installation/ids",
PluginInstalledIdsDaemonResponse,
params={"category": "tool"},
)
assert result == ["langgenius/openai", "langgenius/anthropic"]
def test_fetch_plugin_by_identifier_found(self, plugin_installer):
"""Test fetching a plugin by its unique identifier when it exists."""
# Arrange: Mock successful fetch
@@ -881,7 +881,105 @@ class TestPluginListEndpointCounts:
assert tool_plugin.endpoints_active == 0
class TestPluginCategoryList:
def test_list_by_category_forwards_search_and_tag_filters(self) -> None:
plugins = SimpleNamespace(list=[], has_more=False)
with patch(f"{MODULE}.PluginInstaller") as installer_cls:
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category(
"tenant-1",
PluginCategory.Tool,
2,
25,
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
assert result is plugins
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
"tenant-1",
PluginCategory.Tool,
2,
25,
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
def test_filtered_model_category_does_not_reconcile_from_a_partial_result(self) -> None:
plugins = SimpleNamespace(list=[], has_more=False)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category(
"tenant-1",
PluginCategory.Model,
1,
100,
query="openai",
tags=[],
language="en_US",
)
assert result is plugins
invalidate_cache.assert_not_called()
store_marker.assert_not_called()
class TestInstalledPluginIds:
def test_list_installed_plugin_ids_uses_lightweight_daemon_endpoint(self) -> None:
with patch(f"{MODULE}.PluginInstaller") as installer_cls:
installer_cls.return_value.list_installed_plugin_ids.return_value = [
"langgenius/openai",
"langgenius/anthropic",
]
from core.plugin.plugin_service import PluginService
result = PluginService.list_installed_plugin_ids("tenant-1", PluginCategory.Tool)
assert result == ["langgenius/openai", "langgenius/anthropic"]
installer_cls.return_value.list_installed_plugin_ids.assert_called_once_with("tenant-1", PluginCategory.Tool)
class TestPluginModelProviderCacheInvalidation:
def test_list_model_provider_bindings_reconciles_remote_provider_cache(self) -> None:
"""The summary binding read owns the remote marker once the full category list leaves the first-load path."""
remote_binding = _build_remote_model_plugin()
client = MagicMock()
client.fetch_model_provider_bindings.return_value = [remote_binding]
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
with (
patch(
f"{MODULE}.PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins",
return_value=True,
) as should_invalidate,
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
from core.plugin.plugin_service import PluginService
result = PluginService.list_model_provider_bindings("tenant-1", client=client)
assert result == [remote_binding]
client.fetch_model_provider_bindings.assert_called_once_with("tenant-1")
should_invalidate.assert_called_once_with("tenant-1", [remote_binding])
invalidate_cache.assert_called_once_with("tenant-1")
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
def test_get_debugging_key_does_not_invalidate_model_provider_cache(self) -> None:
"""Reading a debug key does not mean a debug runtime has registered a model provider."""
with (
@@ -925,7 +1023,13 @@ class TestPluginModelProviderCacheInvalidation:
assert result is plugins
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
"tenant-1", PluginCategory.Model, 1, 100
"tenant-1",
PluginCategory.Model,
1,
100,
query="",
tags=(),
language="en_US",
)
invalidate_cache.assert_called_once_with("tenant-1")
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
@@ -992,14 +1096,42 @@ class TestPluginModelProviderCacheInvalidation:
invalidate_cache.assert_not_called()
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
def test_list_model_category_invalidates_when_remote_model_plugin_disconnects(self) -> None:
"""The current model category result clears provider cache when the previous debug model disappears."""
@pytest.mark.parametrize(("page", "has_more"), [(1, True), (2, False)])
def test_list_model_category_does_not_reconcile_partial_page(self, page: int, has_more: bool) -> None:
"""Only an unfiltered, complete first page may write the remote model marker."""
installed_plugin = SimpleNamespace(
plugin_id="langgenius/openai",
plugin_unique_identifier="langgenius/openai:1.0.0",
source=PluginInstallationSource.Marketplace,
)
plugins = SimpleNamespace(list=[installed_plugin], has_more=True)
plugins = SimpleNamespace(list=[installed_plugin], has_more=has_more)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
return_value="langgenius/debug-model:langgenius/debug-model:1.0.0",
),
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, page, 100)
assert result is plugins
invalidate_cache.assert_not_called()
store_marker.assert_not_called()
def test_list_model_category_complete_first_page_reconciles_remote_plugin_disconnect(self) -> None:
installed_plugin = SimpleNamespace(
plugin_id="langgenius/openai",
plugin_unique_identifier="langgenius/openai:1.0.0",
source=PluginInstallationSource.Marketplace,
)
plugins = SimpleNamespace(list=[installed_plugin], has_more=False)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
@@ -5,12 +5,16 @@ from unittest.mock import MagicMock
import pytest
from core.entities.model_entities import ModelStatus
from core.entities.provider_entities import CredentialConfiguration
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginModelProviderBinding
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.model_entities import FetchFrom, ModelType, ParameterRule, ParameterType
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod
from models.provider import ProviderType
from services import model_provider_service as service_module
from services.errors.app_model_config import ProviderNotFoundError
from services.model_provider_service import ModelProviderService
from services.model_provider_service import ModelProviderService, _ProviderSummaryState
def _create_service_with_mocked_manager() -> tuple[ModelProviderService, MagicMock]:
@@ -59,6 +63,26 @@ def _build_provider_configuration(
)
def _build_model_provider_binding(
source: PluginInstallationSource,
*,
installation_id: str = "installation-1",
plugin_id: str = "langgenius/openai",
plugin_unique_identifier: str = "langgenius/openai:1.0.0@checksum",
verified: bool = True,
) -> PluginModelProviderBinding:
return PluginModelProviderBinding(
provider="openai",
installation_id=installation_id,
plugin_id=plugin_id,
plugin_unique_identifier=plugin_unique_identifier,
runtime_type="remote" if source == PluginInstallationSource.Remote else "local",
source=source,
version="1.0.0",
verified=verified,
)
class TestModelProviderServiceConfiguration:
def test__get_provider_configuration_should_return_configuration_when_provider_exists(self) -> None:
service, manager = _create_service_with_mocked_manager()
@@ -96,6 +120,352 @@ class TestModelProviderServiceConfiguration:
assert result[0].provider == "openai"
assert result[0].custom_configuration.status.value == "no-configure"
def test_get_provider_summary_list_uses_lightweight_state_and_plugin_bindings(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
service = ModelProviderService()
provider = SimpleNamespace(
provider="langgenius/openai/openai",
label=I18nObject(en_US="OpenAI"),
description=I18nObject(en_US="OpenAI models"),
icon_small=I18nObject(en_US="icon.svg"),
icon_small_dark=I18nObject(en_US="icon-dark.svg"),
supported_model_types=[ModelType.LLM],
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
)
binding = SimpleNamespace(
provider="openai",
plugin_id="langgenius/openai",
installation_id="installation-1",
plugin_unique_identifier="langgenius/openai:1.2.3@checksum",
runtime_type="local",
source=PluginInstallationSource.Marketplace,
version="1.2.3",
verified=True,
)
state = _ProviderSummaryState(
has_custom_provider=True,
available_credentials=[
CredentialConfiguration(
credential_id="credential-1",
credential_name="Production",
),
CredentialConfiguration(
credential_id="credential-2",
credential_name="Backup",
),
],
has_custom_models=True,
current_credential_id="credential-1",
current_credential_name="Production",
current_credential_usable=True,
preferred_provider_type=ProviderType.CUSTOM,
)
call_order: list[str] = []
manager_constructor = MagicMock(side_effect=AssertionError("summary must not construct ProviderManager"))
monkeypatch.setattr(service, "_get_provider_manager", manager_constructor)
monkeypatch.setattr(
service_module.PluginService,
"list_model_provider_bindings",
MagicMock(side_effect=lambda *_args, **_kwargs: call_order.append("bindings") or [binding]),
)
monkeypatch.setattr(
service_module.PluginService,
"fetch_plugin_model_providers",
MagicMock(side_effect=lambda *_args, **_kwargs: call_order.append("providers") or [provider]),
)
monkeypatch.setattr(
service,
"_load_provider_summary_states",
MagicMock(return_value={provider.provider: state}),
)
monkeypatch.setattr(
service_module.ext_hosting_provider.hosting_configuration,
"provider_map",
{provider.provider: SimpleNamespace(enabled=True, quotas=[SimpleNamespace()])},
)
providers, plugins = service.get_provider_summary_list(tenant_id="tenant-1")
assert len(providers) == 1
assert providers[0].provider == provider.provider
assert providers[0].plugin_id == "langgenius/openai"
assert providers[0].is_configured is True
assert providers[0].custom_configuration.available_credentials == [
CredentialConfiguration(
credential_id="credential-1",
credential_name="Production",
),
CredentialConfiguration(
credential_id="credential-2",
credential_name="Backup",
),
]
assert providers[0].custom_configuration.has_custom_models is True
assert providers[0].custom_configuration.current_credential_name == "Production"
assert providers[0].custom_configuration.current_credential_usable is True
assert providers[0].system_configuration.enabled is True
assert plugins["langgenius/openai"].installation_id == "installation-1"
assert plugins["langgenius/openai"].version == "1.2.3"
assert call_order == ["bindings", "providers"]
manager_constructor.assert_not_called()
def test_get_provider_summary_list_enables_system_only_for_verified_hosted_non_package_bindings(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
marketplace_binding = _build_model_provider_binding(PluginInstallationSource.Marketplace)
package_binding = _build_model_provider_binding(
PluginInstallationSource.Package,
installation_id="installation-package",
plugin_id="langgenius/package",
plugin_unique_identifier="langgenius/package:1.0.0@checksum",
)
unhosted_binding = _build_model_provider_binding(
PluginInstallationSource.Marketplace,
installation_id="installation-unhosted",
plugin_id="langgenius/unhosted",
plugin_unique_identifier="langgenius/unhosted:1.0.0@checksum",
)
remote_binding = _build_model_provider_binding(
PluginInstallationSource.Remote,
installation_id="installation-remote",
plugin_id="langgenius/remote",
plugin_unique_identifier="langgenius/remote:1.0.0@checksum",
)
unverified_binding = _build_model_provider_binding(
PluginInstallationSource.Marketplace,
installation_id="installation-unverified",
plugin_id="langgenius/unverified",
plugin_unique_identifier="langgenius/unverified:1.0.0@checksum",
verified=False,
)
bindings = [
marketplace_binding,
package_binding,
unhosted_binding,
remote_binding,
unverified_binding,
]
provider_entities = [
SimpleNamespace(
provider=f"{binding.plugin_id}/openai",
label=I18nObject(en_US=binding.plugin_id),
description=None,
icon_small=None,
icon_small_dark=None,
supported_model_types=[ModelType.LLM],
configurate_methods=[],
)
for binding in bindings
]
monkeypatch.setattr(
service_module.ext_hosting_provider.hosting_configuration,
"provider_map",
{
"langgenius/openai/openai": SimpleNamespace(enabled=True, quotas=[SimpleNamespace()]),
"langgenius/package/openai": SimpleNamespace(enabled=True, quotas=[SimpleNamespace()]),
"langgenius/remote/openai": SimpleNamespace(enabled=True, quotas=[SimpleNamespace()]),
"langgenius/unverified/openai": SimpleNamespace(enabled=True, quotas=[SimpleNamespace()]),
},
)
monkeypatch.setattr(
service_module.PluginService,
"list_model_provider_bindings",
MagicMock(return_value=bindings),
)
monkeypatch.setattr(
service_module.PluginService,
"fetch_plugin_model_providers",
MagicMock(return_value=provider_entities),
)
monkeypatch.setattr(ModelProviderService, "_load_provider_summary_states", MagicMock(return_value={}))
monkeypatch.setattr(service_module, "is_filtered", MagicMock(return_value=False))
providers, _ = ModelProviderService().get_provider_summary_list("tenant-1")
assert {provider.provider: provider.system_configuration.enabled for provider in providers} == {
"langgenius/openai/openai": True,
"langgenius/package/openai": False,
"langgenius/unhosted/openai": False,
"langgenius/remote/openai": True,
"langgenius/unverified/openai": False,
}
def test_model_provider_binding_without_verified_field_fails_closed(self) -> None:
binding = PluginModelProviderBinding.model_validate(
{
"provider": "openai",
"installation_id": "installation-1",
"plugin_id": "langgenius/openai",
"plugin_unique_identifier": "langgenius/openai:1.0.0@checksum",
"runtime_type": "local",
"source": "marketplace",
"version": "1.0.0",
}
)
assert binding.verified is False
def test_get_provider_summary_list_returns_all_unique_provider_metadata(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
service = ModelProviderService()
llm_provider = SimpleNamespace(
provider="langgenius/openai/openai",
label=I18nObject(en_US="OpenAI"),
description=None,
icon_small=None,
icon_small_dark=None,
supported_model_types=[ModelType.LLM],
configurate_methods=[],
)
embedding_provider = SimpleNamespace(
provider="langgenius/embedding/embedding",
label=I18nObject(en_US="Embedding"),
description=None,
icon_small=None,
icon_small_dark=None,
supported_model_types=[ModelType.TEXT_EMBEDDING],
configurate_methods=[],
)
llm_binding = SimpleNamespace(
provider="openai",
plugin_id="langgenius/openai",
installation_id="installation-openai",
plugin_unique_identifier="langgenius/openai:1.0.0@checksum",
runtime_type="local",
source=service_module.PluginInstallationSource.Marketplace,
version="1.0.0",
verified=False,
)
embedding_binding = SimpleNamespace(
provider="embedding",
plugin_id="langgenius/embedding",
installation_id="installation-embedding",
plugin_unique_identifier="langgenius/embedding:1.0.0@checksum",
runtime_type="local",
source=service_module.PluginInstallationSource.Marketplace,
version="1.0.0",
verified=False,
)
monkeypatch.setattr(
service_module.PluginService,
"list_model_provider_bindings",
MagicMock(return_value=[llm_binding, embedding_binding]),
)
monkeypatch.setattr(
service_module.PluginService,
"fetch_plugin_model_providers",
MagicMock(return_value=[llm_provider, llm_provider, embedding_provider]),
)
monkeypatch.setattr(service, "_load_provider_summary_states", MagicMock(return_value={}))
monkeypatch.setattr(service_module, "is_filtered", MagicMock(return_value=False))
providers, plugins = service.get_provider_summary_list(tenant_id="tenant-1")
assert [provider.provider for provider in providers] == [
"langgenius/openai/openai",
"langgenius/embedding/embedding",
]
assert providers[0].is_configured is False
assert providers[0].custom_configuration.status.value == "no-configure"
assert providers[0].custom_configuration.has_custom_models is False
assert providers[0].custom_configuration.available_credentials == []
assert set(plugins) == {"langgenius/openai", "langgenius/embedding"}
def test_preferred_provider_fallback_uses_custom_presence_not_configuration_status(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(service_module.dify_config, "EDITION", "SELF_HOSTED")
state = _ProviderSummaryState(has_custom_provider=True)
preferred_provider_type = ModelProviderService._get_preferred_provider_type(
state,
custom_present=True,
system_enabled=True,
)
assert preferred_provider_type == ProviderType.CUSTOM
def test_load_provider_summary_states_reads_only_lightweight_columns(self, monkeypatch: pytest.MonkeyPatch) -> None:
canonical_provider = "langgenius/openai/openai"
session = MagicMock()
session.execute.side_effect = [
SimpleNamespace(
all=lambda: [
SimpleNamespace(
provider_name="openai",
credential_id="credential-legacy",
credential_provider_name="openai",
credential_name="Legacy",
),
SimpleNamespace(
provider_name=canonical_provider,
credential_id="credential-current",
credential_provider_name=canonical_provider,
credential_name="Production",
),
]
),
SimpleNamespace(
all=lambda: [
SimpleNamespace(
id="credential-legacy",
provider_name="openai",
credential_name="Legacy",
),
SimpleNamespace(
id="credential-current",
provider_name=canonical_provider,
credential_name="Production",
),
]
),
SimpleNamespace(all=lambda: [SimpleNamespace(provider_name="openai")]),
SimpleNamespace(
all=lambda: [
SimpleNamespace(
provider_name=canonical_provider,
preferred_provider_type=ProviderType.SYSTEM,
)
]
),
]
session_context = MagicMock()
session_context.__enter__.return_value = session
create_session = MagicMock(return_value=session_context)
monkeypatch.setattr(service_module.session_factory, "create_session", create_session)
states = ModelProviderService._load_provider_summary_states("tenant-1")
state = states[canonical_provider]
assert state.has_custom_provider is True
assert state.available_credentials == [
CredentialConfiguration(
credential_id="credential-legacy",
credential_name="Legacy",
),
CredentialConfiguration(
credential_id="credential-current",
credential_name="Production",
),
]
assert state.has_custom_models is True
assert state.current_credential_id == "credential-current"
assert state.current_credential_name == "Production"
assert state.current_credential_usable is True
assert state.preferred_provider_type == ProviderType.SYSTEM
statements = [str(execute_call.args[0]) for execute_call in session.execute.call_args_list]
assert len(statements) == 4
assert all("encrypted_config" not in statement for statement in statements)
assert "count(" not in statements[1].lower()
assert "provider_credentials.id" in statements[1]
assert "provider_credentials.credential_name" in statements[1]
assert "ORDER BY provider_credentials.created_at DESC, provider_credentials.id DESC" in statements[1]
assert "provider_model_credentials" in statements[2]
def test_get_models_by_provider_should_wrap_model_entities_with_tenant_context(self) -> None:
service, manager = _create_service_with_mocked_manager()
@@ -0,0 +1,76 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from enums.cloud_plan import CloudPlan
from services.credit_pool_service import CreditPoolBalance
from services.workspace_service import WorkspaceService
@pytest.mark.parametrize(
("quota_limit", "quota_used", "remaining_credits", "is_unlimited"),
[(500, 120, 380, False), (-1, 999, -1, True)],
)
def test_get_effective_credit_pool_prefers_available_paid_pool(
quota_limit: int, quota_used: int, remaining_credits: int, is_unlimited: bool
) -> None:
session = MagicMock()
paid_pool = CreditPoolBalance(
tenant_id="tenant-1",
pool_type="paid",
quota_limit=quota_limit,
quota_used=quota_used,
)
billing_info = {
"enabled": True,
"subscription": {"plan": CloudPlan.TEAM},
"next_credit_reset_date": 1775001600,
}
config = SimpleNamespace(BILLING_ENABLED=True)
with (
patch("services.workspace_service.dify_config", config),
patch("services.workspace_service.BillingService.get_info", return_value=billing_info),
patch("services.credit_pool_service.CreditPoolService.get_pool", return_value=paid_pool) as get_pool,
):
result = WorkspaceService.get_effective_credit_pool("tenant-1", session=session)
get_pool.assert_called_once_with(tenant_id="tenant-1", pool_type="paid", session=session)
assert result.pool_type == "paid"
assert result.quota_limit == quota_limit
assert result.quota_used == quota_used
assert result.remaining_credits == remaining_credits
assert result.is_unlimited is is_unlimited
assert result.is_exhausted is False
assert result.next_credit_reset_date == 1775001600
def test_get_effective_credit_pool_exposes_exhausted_trial_pool() -> None:
session = MagicMock()
trial_pool = CreditPoolBalance(
tenant_id="tenant-1",
pool_type="trial",
quota_limit=200,
quota_used=200,
exhausted_at=1772323200,
)
billing_info = {
"enabled": True,
"subscription": {"plan": CloudPlan.SANDBOX},
}
config = SimpleNamespace(BILLING_ENABLED=True)
with (
patch("services.workspace_service.dify_config", config),
patch("services.workspace_service.BillingService.get_info", return_value=billing_info),
patch("services.credit_pool_service.CreditPoolService.get_pool", return_value=trial_pool) as get_pool,
):
result = WorkspaceService.get_effective_credit_pool("tenant-1", session=session)
get_pool.assert_called_once_with(tenant_id="tenant-1", pool_type="trial", session=session)
assert result.pool_type == "trial"
assert result.remaining_credits == 0
assert result.is_unlimited is False
assert result.is_exhausted is True
assert result.exhausted_at == 1772323200
-29
View File
@@ -2891,14 +2891,6 @@
"count": 4
}
},
"web/app/components/header/account-setting/model-provider-page/hooks.ts": {
"eslint-react/no-unnecessary-use-prefix": {
"count": 1
},
"typescript/no-explicit-any": {
"count": 2
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2
@@ -2923,14 +2915,6 @@
"count": 1
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -2974,11 +2958,6 @@
"count": 5
}
},
"web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -3368,14 +3347,6 @@
"count": 25
}
},
"web/app/components/plugins/update-plugin/plugin-version-picker.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
File diff suppressed because it is too large Load Diff
@@ -227,6 +227,24 @@ export type ModelProviderListResponse = {
data: Array<ProviderResponse>
}
export type ModelProviderCreditsResponse = {
exhausted_at: number | null
is_exhausted: boolean
is_unlimited: boolean
next_credit_reset_date: number | null
pool_type: 'paid' | 'trial' | null
quota_limit: number | null
quota_used: number | null
remaining_credits: number | null
}
export type ModelProviderSummaryListResponse = {
data: Array<ModelProviderSummaryResponse>
plugins: {
[key: string]: ModelProviderPluginSummaryResponse
}
}
export type ModelProviderPaymentCheckoutUrlResponse = {
payment_link: string
}
@@ -417,6 +435,10 @@ export type ParserPluginIdentifiers = {
plugin_unique_identifiers: Array<string>
}
export type PluginInstalledIdsResponse = {
plugin_ids: Array<string>
}
export type PluginListResponse = {
plugins: Array<PluginEntity>
total: number
@@ -1192,6 +1214,30 @@ export type ProviderResponse = {
tenant_id: string
}
export type ModelProviderSummaryResponse = {
configurate_methods: Array<ConfigurateMethod>
custom_configuration: ModelProviderCustomConfigurationSummaryResponse
description?: I18nObject | null
icon_small?: I18nObject | null
icon_small_dark?: I18nObject | null
is_configured: boolean
label: I18nObject
plugin_id: string
preferred_provider_type: ProviderType
provider: string
supported_model_types: Array<ModelType>
system_configuration: ModelProviderSystemConfigurationSummaryResponse
}
export type ModelProviderPluginSummaryResponse = {
installation_id: string
plugin_id: string
plugin_unique_identifier: string
runtime_type: string
source: PluginInstallationSource
version: string
}
export type ModelType = 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts'
export type ModelWithProviderEntityResponse = {
@@ -1730,6 +1776,21 @@ export type SystemConfigurationResponse = {
quota_configurations?: Array<QuotaConfiguration>
}
export type ModelProviderCustomConfigurationSummaryResponse = {
available_credentials: Array<CredentialConfiguration>
current_credential_id?: string | null
current_credential_name?: string | null
current_credential_usable: boolean
has_custom_models: boolean
status: CustomConfigurationStatus
}
export type ModelProviderSystemConfigurationSummaryResponse = {
enabled: boolean
}
export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote'
export type ModelFeature =
| 'agent-thought'
| 'audio'
@@ -1900,8 +1961,6 @@ export type PluginInstallTaskPluginStatus = {
export type PluginInstallTaskStatus = 'failed' | 'pending' | 'running' | 'success'
export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote'
export type PluginDeclarationResponse = {
agent_strategy?: {
[key: string]: unknown
@@ -3029,6 +3088,34 @@ export type GetWorkspacesCurrentModelProvidersResponses = {
export type GetWorkspacesCurrentModelProvidersResponse =
GetWorkspacesCurrentModelProvidersResponses[keyof GetWorkspacesCurrentModelProvidersResponses]
export type GetWorkspacesCurrentModelProvidersCreditsData = {
body?: never
path?: never
query?: never
url: '/workspaces/current/model-providers/credits'
}
export type GetWorkspacesCurrentModelProvidersCreditsResponses = {
200: ModelProviderCreditsResponse
}
export type GetWorkspacesCurrentModelProvidersCreditsResponse =
GetWorkspacesCurrentModelProvidersCreditsResponses[keyof GetWorkspacesCurrentModelProvidersCreditsResponses]
export type GetWorkspacesCurrentModelProvidersSummaryData = {
body?: never
path?: never
query?: never
url: '/workspaces/current/model-providers/summary'
}
export type GetWorkspacesCurrentModelProvidersSummaryResponses = {
200: ModelProviderSummaryListResponse
}
export type GetWorkspacesCurrentModelProvidersSummaryResponse =
GetWorkspacesCurrentModelProvidersSummaryResponses[keyof GetWorkspacesCurrentModelProvidersSummaryResponses]
export type GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlData = {
body?: never
path: {
@@ -3575,6 +3662,22 @@ export type PostWorkspacesCurrentPluginInstallPkgResponses = {
export type PostWorkspacesCurrentPluginInstallPkgResponse =
PostWorkspacesCurrentPluginInstallPkgResponses[keyof PostWorkspacesCurrentPluginInstallPkgResponses]
export type GetWorkspacesCurrentPluginInstalledIdsData = {
body?: never
path?: never
query: {
category: 'agent-strategy' | 'datasource' | 'extension' | 'model' | 'tool' | 'trigger'
}
url: '/workspaces/current/plugin/installed-ids'
}
export type GetWorkspacesCurrentPluginInstalledIdsResponses = {
200: PluginInstalledIdsResponse
}
export type GetWorkspacesCurrentPluginInstalledIdsResponse =
GetWorkspacesCurrentPluginInstalledIdsResponses[keyof GetWorkspacesCurrentPluginInstalledIdsResponses]
export type GetWorkspacesCurrentPluginListData = {
body?: never
path?: never
@@ -3888,8 +3991,11 @@ export type GetWorkspacesCurrentPluginByCategoryListData = {
category: string
}
query?: {
language?: 'en_US' | 'ja_JP' | 'pt_BR' | 'zh_Hans'
page?: number
page_size?: number
query?: string
tags?: Array<string>
}
url: '/workspaces/current/plugin/{category}/list'
}
@@ -158,6 +158,20 @@ export const zMemberRoleUpdatePayload = z.object({
role: z.string(),
})
/**
* ModelProviderCreditsResponse
*/
export const zModelProviderCreditsResponse = z.object({
exhausted_at: z.int().nullable(),
is_exhausted: z.boolean(),
is_unlimited: z.boolean(),
next_credit_reset_date: z.int().nullable(),
pool_type: z.enum(['paid', 'trial']).nullable(),
quota_limit: z.int().nullable(),
quota_used: z.int().nullable(),
remaining_credits: z.int().nullable(),
})
/**
* ModelProviderPaymentCheckoutUrlResponse
*/
@@ -283,6 +297,13 @@ export const zParserPluginIdentifiers = z.object({
plugin_unique_identifiers: z.array(z.string()),
})
/**
* PluginInstalledIdsResponse
*/
export const zPluginInstalledIdsResponse = z.object({
plugin_ids: z.array(z.string()),
})
/**
* ParserLatest
*/
@@ -1613,6 +1634,30 @@ export const zConfigurateMethod = z.enum(['customizable-model', 'predefined-mode
*/
export const zProviderType = z.enum(['custom', 'system'])
/**
* ModelProviderSystemConfigurationSummaryResponse
*/
export const zModelProviderSystemConfigurationSummaryResponse = z.object({
enabled: z.boolean(),
})
/**
* PluginInstallationSource
*/
export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote'])
/**
* ModelProviderPluginSummaryResponse
*/
export const zModelProviderPluginSummaryResponse = z.object({
installation_id: z.string(),
plugin_id: z.string(),
plugin_unique_identifier: z.string(),
runtime_type: z.string(),
source: zPluginInstallationSource,
version: z.string(),
})
/**
* ModelFeature
*
@@ -1803,6 +1848,46 @@ export const zAvailableModelListResponse = z.object({
data: z.array(zProviderWithModelsResponse),
})
/**
* ModelProviderCustomConfigurationSummaryResponse
*/
export const zModelProviderCustomConfigurationSummaryResponse = z.object({
available_credentials: z.array(zCredentialConfiguration),
current_credential_id: z.string().nullish(),
current_credential_name: z.string().nullish(),
current_credential_usable: z.boolean(),
has_custom_models: z.boolean(),
status: zCustomConfigurationStatus,
})
/**
* ModelProviderSummaryResponse
*
* Fields required to render the collapsed model-provider list.
*/
export const zModelProviderSummaryResponse = z.object({
configurate_methods: z.array(zConfigurateMethod),
custom_configuration: zModelProviderCustomConfigurationSummaryResponse,
description: zI18nObject.nullish(),
icon_small: zI18nObject.nullish(),
icon_small_dark: zI18nObject.nullish(),
is_configured: z.boolean(),
label: zI18nObject,
plugin_id: z.string(),
preferred_provider_type: zProviderType,
provider: z.string(),
supported_model_types: z.array(zModelType),
system_configuration: zModelProviderSystemConfigurationSummaryResponse,
})
/**
* ModelProviderSummaryListResponse
*/
export const zModelProviderSummaryListResponse = z.object({
data: z.array(zModelProviderSummaryResponse),
plugins: z.record(z.string(), zModelProviderPluginSummaryResponse),
})
/**
* TenantPluginAutoUpgradeStrategySetting
*/
@@ -1948,11 +2033,6 @@ export const zPluginTaskResponse = z.object({
task: zPluginInstallTask,
})
/**
* PluginInstallationSource
*/
export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote'])
/**
* PluginBundleDependencyType
*/
@@ -3696,6 +3776,16 @@ export const zGetWorkspacesCurrentModelProvidersQuery = z.object({
*/
export const zGetWorkspacesCurrentModelProvidersResponse = zModelProviderListResponse
/**
* Model provider credits retrieved successfully
*/
export const zGetWorkspacesCurrentModelProvidersCreditsResponse = zModelProviderCreditsResponse
/**
* Model provider summaries retrieved successfully
*/
export const zGetWorkspacesCurrentModelProvidersSummaryResponse = zModelProviderSummaryListResponse
export const zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlPath = z.object({
provider: z.string(),
})
@@ -4071,6 +4161,15 @@ export const zPostWorkspacesCurrentPluginInstallPkgBody = zParserPluginIdentifie
*/
export const zPostWorkspacesCurrentPluginInstallPkgResponse = zPluginInstallTaskStartResponse
export const zGetWorkspacesCurrentPluginInstalledIdsQuery = z.object({
category: z.enum(['agent-strategy', 'datasource', 'extension', 'model', 'tool', 'trigger']),
})
/**
* Success
*/
export const zGetWorkspacesCurrentPluginInstalledIdsResponse = zPluginInstalledIdsResponse
export const zGetWorkspacesCurrentPluginListQuery = z.object({
page: z.int().gte(1).optional().default(1),
page_size: z.int().gte(1).lte(256).optional().default(256),
@@ -4241,8 +4340,11 @@ export const zGetWorkspacesCurrentPluginByCategoryListPath = z.object({
})
export const zGetWorkspacesCurrentPluginByCategoryListQuery = z.object({
language: z.enum(['en_US', 'ja_JP', 'pt_BR', 'zh_Hans']).optional().default('en_US'),
page: z.int().gte(1).optional().default(1),
page_size: z.int().gte(1).lte(256).optional().default(256),
query: z.string().max(256).optional().default(''),
tags: z.array(z.string()).max(128).optional(),
})
/**
+5 -1
View File
@@ -7,9 +7,12 @@ import { defaultPlan } from '@/app/components/billing/config'
// Avoid being mocked in tests
export const baseProviderContextValue: ProviderContextState = {
modelProviders: [],
modelProviderPlugins: {},
refreshModelProviders: async () => {},
isLoadingModelProviders: false,
isSuccessModelProviders: false,
textGenerationModelList: [],
supportRetrievalMethods: [],
isAPIKeySet: true,
plan: defaultPlan,
isFetchedPlan: false,
@@ -18,6 +21,7 @@ export const baseProviderContextValue: ProviderContextState = {
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
datasetOperatorEnabled: false,
enableEducationPlan: false,
isEducationWorkspace: false,
isEducationAccount: false,
@@ -45,7 +49,7 @@ export const createMockProviderContextValue = (
return {
...merged,
refreshModelProviders: merged.refreshModelProviders ?? (async () => {}),
refreshModelProviders: merged.refreshModelProviders ?? noop,
onPlanInfoChanged: merged.onPlanInfoChanged ?? noop,
refreshLicenseLimit: merged.refreshLicenseLimit ?? noop,
}
@@ -1,3 +1,4 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import type { ModelAndParameter } from '../../types'
import type {
@@ -158,7 +159,13 @@ describe('ModelParameterTrigger', () => {
})
mockUseProviderContext.mockReturnValue(
createMockProviderContextValue({
modelProviders: [createModelProvider()],
modelProviders: [
{
...createModelProvider(),
is_configured: true,
plugin_id: 'langgenius/openai',
} as unknown as ModelProviderSummaryResponse,
],
}),
)
mockUseCredentialPanelState.mockReturnValue({
@@ -39,9 +39,12 @@ const mockUseProviderContext = actualUseProviderContext as MockedFunction<
// Default mock data
const defaultProviderContext = {
modelProviders: [],
modelProviderPlugins: {},
refreshModelProviders: async () => {},
isLoadingModelProviders: false,
isSuccessModelProviders: false,
textGenerationModelList: [],
supportRetrievalMethods: [],
isAPIKeySet: false,
plan: defaultPlan,
isFetchedPlan: false,
@@ -50,6 +53,7 @@ const defaultProviderContext = {
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
datasetOperatorEnabled: false,
enableEducationPlan: false,
isEducationWorkspace: false,
isEducationAccount: false,
@@ -44,7 +44,7 @@ let mockModelProvidersData: {
vi.mock('@/service/use-common', () => ({
useCodeBasedExtensions: () => mockCodeBasedExtensions,
useModelProviders: () => mockModelProvidersData,
useModelProviderDetails: () => mockModelProvidersData,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
@@ -18,7 +18,7 @@ import {
} from '@/app/components/header/account-setting/query-params'
import { useDocLink, useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { useCodeBasedExtensions, useModelProviders } from '@/service/use-common'
import { useCodeBasedExtensions, useModelProviderDetails } from '@/service/use-common'
import FormGeneration from './form-generation'
import ModerationContent from './moderation-content'
@@ -59,7 +59,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCance
const { t } = useTranslation()
const docLink = useDocLink()
const locale = useLocale()
const { data: modelProviders, isPending: isLoading } = useModelProviders()
const { data: modelProviders, isPending: isLoading } = useModelProviderDetails()
const localeDataRef = useRef<ModerationConfig>(data)
const [localeData, setLocaleData] = useState<ModerationConfig>(data)
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
@@ -199,16 +199,15 @@ describe('Card Component', () => {
// Act
render(<Card item={mockItem} />)
// Assert
// Assert
expect(screen.getByText('Test Label'))!.toBeInTheDocument()
expect(screen.queryByText(/Test Author/))!.not.toBeInTheDocument()
expect(screen.queryByText(/test-name/))!.not.toBeInTheDocument()
expect(screen.getByText('1.2.0'))!.toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Test Label' }))!.toHaveAttribute(
'src',
'test-icon-url',
)
const icon = screen.getByRole('img', { name: 'Test Label' })
expect(icon).toHaveAttribute('src', 'test-icon-url')
expect(icon).toHaveAttribute('loading', 'lazy')
expect(icon).toHaveAttribute('decoding', 'async')
expect(screen.getByText('Credential 1'))!.toBeInTheDocument()
expect(screen.getByText(/plugin.auth.default/))!.toBeInTheDocument()
@@ -5,6 +5,7 @@ import { fireEvent, screen } from '@testing-library/react'
import { useTheme } from 'next-themes'
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import {
useGetDataSourceListAuth,
@@ -301,6 +302,9 @@ describe('DataSourcePage Component', () => {
// Assert
expect(screen.getByTestId('plugin-actions-plugin-1')).toBeInTheDocument()
expect(useInstalledPluginList).toHaveBeenLastCalledWith({
category: PluginCategoryEnum.datasource,
})
})
it('should filter installed data sources and pass search text to marketplace', () => {
@@ -108,6 +108,8 @@ const Card = ({ item, disabled, pluginDetail, onPluginUpdate }: CardProps) => {
alt={providerLabel}
width={20}
height={20}
loading="lazy"
decoding="async"
className="h-5 w-5 object-contain"
/>
</div>
@@ -63,7 +63,9 @@ const DataSourcePage = ({ layout, onOpenMarketplace, stickyToolbar }: DataSource
select: (s) => s.enable_marketplace,
})
const { data, isLoading: isDataSourceListLoading } = useGetDataSourceListAuth()
const { data: installedPluginList } = useInstalledPluginList()
const { data: installedPluginList } = useInstalledPluginList({
category: PluginCategoryEnum.datasource,
})
const pluginListWithLatestVersion = usePluginsWithLatestVersion(installedPluginList?.plugins)
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const invalidateDataSourceListAuth = useInvalidDataSourceListAuth()
@@ -21,7 +21,7 @@ import {
PreferredProviderTypeEnum,
} from '../declarations'
import {
useCurrentProviderAndModel,
getCurrentProviderAndModel,
useDefaultModel,
useInvalidateDefaultModel,
useLanguage,
@@ -58,6 +58,7 @@ vi.mock('@/service/use-common', () => ({
commonQueryKeys: {
modelList: (type: string) => ['model-list', type],
modelProviders: ['model-providers'],
modelProviderDetails: ['model-provider-details'],
defaultModel: (type: string) => ['default-model', type],
},
}))
@@ -293,6 +294,23 @@ describe('hooks', () => {
expect(result.current.data).toEqual([])
})
it('should keep the query disabled when requested', () => {
;(useQuery as Mock).mockReturnValue({
data: undefined,
isPending: true,
refetch: vi.fn(),
})
renderHook(() => useModelList(ModelTypeEnum.textEmbedding, { enabled: false }))
expect(useQuery).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
queryKey: ['model-list', ModelTypeEnum.textEmbedding],
}),
)
})
it('should handle loading state', () => {
;(useQuery as Mock).mockReturnValue({
data: undefined,
@@ -411,7 +429,7 @@ describe('hooks', () => {
})
})
describe('useCurrentProviderAndModel', () => {
describe('getCurrentProviderAndModel', () => {
const createModelList = (): Model[] => [
{
provider: 'openai',
@@ -445,7 +463,7 @@ describe('hooks', () => {
const modelList = createModelList()
const defaultModel = { provider: 'openai', model: 'gpt-4' }
const { result } = renderHook(() => useCurrentProviderAndModel(modelList, defaultModel))
const { result } = renderHook(() => getCurrentProviderAndModel(modelList, defaultModel))
expect(result.current.currentProvider?.provider).toBe('openai')
expect(result.current.currentModel?.model).toBe('gpt-4')
@@ -455,7 +473,7 @@ describe('hooks', () => {
const modelList = createModelList()
const defaultModel = { provider: 'anthropic', model: 'claude-3' }
const { result } = renderHook(() => useCurrentProviderAndModel(modelList, defaultModel))
const { result } = renderHook(() => getCurrentProviderAndModel(modelList, defaultModel))
expect(result.current.currentProvider).toBeUndefined()
expect(result.current.currentModel).toBeUndefined()
@@ -465,7 +483,7 @@ describe('hooks', () => {
const modelList = createModelList()
const defaultModel = { provider: 'openai', model: 'gpt-5' }
const { result } = renderHook(() => useCurrentProviderAndModel(modelList, defaultModel))
const { result } = renderHook(() => getCurrentProviderAndModel(modelList, defaultModel))
expect(result.current.currentProvider?.provider).toBe('openai')
expect(result.current.currentModel).toBeUndefined()
@@ -474,7 +492,7 @@ describe('hooks', () => {
it('should handle undefined default model', () => {
const modelList = createModelList()
const { result } = renderHook(() => useCurrentProviderAndModel(modelList, undefined))
const { result } = renderHook(() => getCurrentProviderAndModel(modelList, undefined))
expect(result.current.currentProvider).toBeUndefined()
expect(result.current.currentModel).toBeUndefined()
@@ -483,7 +501,7 @@ describe('hooks', () => {
it('should handle empty model list', () => {
const defaultModel = { provider: 'openai', model: 'gpt-4' }
const { result } = renderHook(() => useCurrentProviderAndModel([], defaultModel))
const { result } = renderHook(() => getCurrentProviderAndModel([], defaultModel))
expect(result.current.currentProvider).toBeUndefined()
expect(result.current.currentModel).toBeUndefined()
@@ -754,7 +772,10 @@ describe('hooks', () => {
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['model-providers'],
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['model-provider-details'],
})
})
@@ -770,55 +791,17 @@ describe('hooks', () => {
result.current()
})
expect(invalidateQueries).toHaveBeenCalledTimes(3)
expect(invalidateQueries).toHaveBeenCalledTimes(6)
})
})
describe('useMarketplaceAllPlugins', () => {
const createMockProviders = (): ModelProvider[] => [
{
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
supported_model_types: [ModelTypeEnum.textGeneration],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: { credential_form_schemas: [] },
model_credential_schema: {
model: {
label: { en_US: 'Model', zh_Hans: '模型' },
placeholder: { en_US: 'Select model', zh_Hans: '选择模型' },
},
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.system,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [],
},
help: {
title: {
en_US: '',
zh_Hans: '',
},
url: {
en_US: '',
zh_Hans: '',
},
},
},
]
const createMockPlugins = () => [
{ plugin_id: 'plugin1', type: 'plugin' },
{ plugin_id: 'plugin2', type: 'plugin' },
]
it('should combine collection and regular plugins', () => {
const providers = createMockProviders()
const collectionPlugins = [{ plugin_id: 'collection1', type: 'plugin' }]
const regularPlugins = createMockPlugins()
;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
@@ -832,14 +815,13 @@ describe('hooks', () => {
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins(providers, ''))
const { result } = renderHook(() => useMarketplaceAllPlugins('', []))
expect(result.current.plugins).toHaveLength(3)
expect(result.current.isLoading).toBe(false)
})
it('should exclude installed providers', () => {
const providers = createMockProviders()
const collectionPlugins = [
{ plugin_id: 'openai', type: 'plugin' },
{ plugin_id: 'other', type: 'plugin' },
@@ -849,16 +831,22 @@ describe('hooks', () => {
isLoading: false,
})
;(useMarketplacePlugins as Mock).mockReturnValue({
plugins: [],
plugins: [
{ plugin_id: 'openai', type: 'plugin' },
{ plugin_id: 'regular-only', type: 'plugin' },
],
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins(providers, ''))
const { result } = renderHook(() => useMarketplaceAllPlugins('', ['openai']))
expect(result.current.plugins!).toHaveLength(1)
expect(result.current.plugins![0]!.plugin_id).toBe('other')
expect(result.current.plugins!).toHaveLength(2)
expect(result.current.plugins!.map((plugin) => plugin.plugin_id)).toEqual([
'other',
'regular-only',
])
})
it('should use search when searchText is provided', () => {
@@ -874,7 +862,7 @@ describe('hooks', () => {
isLoading: false,
})
renderHook(() => useMarketplaceAllPlugins([], 'test search'))
renderHook(() => useMarketplaceAllPlugins('test search', []))
expect(queryPluginsWithDebounced).toHaveBeenCalled()
})
@@ -895,7 +883,7 @@ describe('hooks', () => {
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
const { result } = renderHook(() => useMarketplaceAllPlugins('', []))
expect(result.current.plugins!).toHaveLength(1)
expect(result.current.plugins![0]!.plugin_id).toBe('plugin1')
@@ -914,7 +902,7 @@ describe('hooks', () => {
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
const { result } = renderHook(() => useMarketplaceAllPlugins('', []))
expect(result.current.plugins).toHaveLength(2)
expect(result.current.plugins!.filter((p) => p.plugin_id === 'shared-plugin')).toHaveLength(1)
@@ -932,7 +920,7 @@ describe('hooks', () => {
isLoading: true,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
const { result } = renderHook(() => useMarketplaceAllPlugins('', []))
expect(result.current.isLoading).toBe(true)
})
@@ -949,7 +937,7 @@ describe('hooks', () => {
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
const { result } = renderHook(() => useMarketplaceAllPlugins('', []))
expect(result.current.plugins).toBeDefined()
expect(result.current.isLoading).toBe(false)
@@ -969,13 +957,33 @@ describe('hooks', () => {
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], 'openai'))
const { result } = renderHook(() => useMarketplaceAllPlugins('openai', []))
expect(result.current.plugins).toEqual(searchPlugins)
expect(result.current.plugins?.some((p) => p.plugin_id === 'collection-only')).toBe(false)
})
it('should skip marketplace queries when disabled', () => {
it('should hide installed plugins when a search response is stale', () => {
;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: [],
isLoading: false,
})
;(useMarketplacePlugins as Mock).mockReturnValue({
plugins: [
{ plugin_id: 'langgenius/openai', type: 'plugin' },
{ plugin_id: 'langgenius/other', type: 'plugin' },
],
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins('openai', ['langgenius/openai']))
expect(result.current.plugins).toEqual([{ plugin_id: 'langgenius/other', type: 'plugin' }])
})
it('should preserve marketplace cache when disabled', () => {
const queryPlugins = vi.fn()
const queryPluginsWithDebounced = vi.fn()
const cancelQueryPluginsWithDebounced = vi.fn()
@@ -993,13 +1001,14 @@ describe('hooks', () => {
isLoading: true,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], '', false))
const { result } = renderHook(() => useMarketplaceAllPlugins('', [], false))
expect(useMarketplacePluginsByCollectionId).toHaveBeenCalledWith(undefined)
expect(queryPlugins).not.toHaveBeenCalled()
expect(queryPluginsWithDebounced).not.toHaveBeenCalled()
expect(cancelQueryPluginsWithDebounced).toHaveBeenCalled()
expect(resetPlugins).toHaveBeenCalled()
expect(resetPlugins).not.toHaveBeenCalled()
expect(useMarketplacePlugins).toHaveBeenCalledWith(false)
expect(result.current.plugins).toEqual([])
expect(result.current.isLoading).toBe(false)
})
@@ -1065,7 +1074,12 @@ describe('hooks', () => {
exact: true,
refetchType: 'none',
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-providers'] })
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['model-provider-details'],
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['model-list', ModelTypeEnum.textGeneration],
})
@@ -1208,7 +1222,12 @@ describe('hooks', () => {
result.current.handleRefreshModel(provider)
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-providers'] })
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['model-provider-details'],
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['model-list', ModelTypeEnum.textGeneration],
})
@@ -49,6 +49,18 @@ const { mockReferenceSetting, mockAutoUpgradeError } = vi.hoisted(() => ({
const { mockProviderContextState, mockRefreshModelProviders } = vi.hoisted(() => ({
mockProviderContextState: {
isLoadingModelProviders: false,
isSuccessModelProviders: true,
modelProviderPlugins: {} as Record<
string,
{
installation_id: string
plugin_id: string
plugin_unique_identifier: string
runtime_type: string
source: 'github' | 'marketplace' | 'package' | 'remote'
version: string
}
>,
},
mockRefreshModelProviders: vi.fn(),
}))
@@ -159,9 +171,22 @@ const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail
}
}
const mockProviders = [
type MockProvider = {
provider: string
plugin_id?: string
label: { en_US: string }
custom_configuration: { status: CustomConfigurationStatusEnum }
system_configuration: {
enabled: boolean
current_quota_type: CurrentSystemQuotaTypeEnum
quota_configurations: (typeof mockQuotaConfig)[]
}
}
const mockProviders: MockProvider[] = [
{
provider: 'openai',
plugin_id: 'langgenius/openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
@@ -172,6 +197,7 @@ const mockProviders = [
},
{
provider: 'anthropic',
plugin_id: 'langgenius/anthropic',
label: { en_US: 'Anthropic' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
@@ -184,8 +210,15 @@ const mockProviders = [
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: mockProviders,
modelProviders: mockProviders.map((provider) => ({
...provider,
is_configured:
provider.custom_configuration.status === CustomConfigurationStatusEnum.active ||
provider.system_configuration.enabled,
})),
modelProviderPlugins: mockProviderContextState.modelProviderPlugins,
isLoadingModelProviders: mockProviderContextState.isLoadingModelProviders,
isSuccessModelProviders: mockProviderContextState.isSuccessModelProviders,
refreshModelProviders: mockRefreshModelProviders,
}),
}))
@@ -211,17 +244,17 @@ vi.mock('../provider-added-card', () => ({
default: ({
notConfigured,
provider,
pluginDetail,
pluginSummary,
}: {
notConfigured?: boolean
provider: { provider: string }
pluginDetail?: { plugin_id: string; source?: string }
pluginSummary?: { plugin_id: string; source?: string }
}) => (
<div
data-testid="provider-card"
data-not-configured={String(!!notConfigured)}
data-plugin-id={pluginDetail?.plugin_id ?? ''}
data-plugin-source={pluginDetail?.source ?? ''}
data-plugin-id={pluginSummary?.plugin_id ?? ''}
data-plugin-source={pluginSummary?.source ?? ''}
>
{provider.provider}
</div>
@@ -355,6 +388,8 @@ describe('ModelProviderPage', () => {
mockRefreshModelProviders.mockClear()
mockInstalledModelPlugins.value = []
mockProviderContextState.isLoadingModelProviders = false
mockProviderContextState.isSuccessModelProviders = true
mockProviderContextState.modelProviderPlugins = {}
mockAutoUpgradeError.value = undefined
mockReferenceSetting.auto_upgrade = {
strategy_setting: 'latest',
@@ -371,6 +406,7 @@ describe('ModelProviderPage', () => {
mockProviders.length,
{
provider: 'openai',
plugin_id: 'langgenius/openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
@@ -381,6 +417,7 @@ describe('ModelProviderPage', () => {
},
{
provider: 'anthropic',
plugin_id: 'langgenius/anthropic',
label: { en_US: 'Anthropic' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
@@ -531,9 +568,10 @@ describe('ModelProviderPage', () => {
expect(target).toContainElement(screen.getByText('common.modelProvider.emptyProviderTitle'))
})
it('should use the model plugin installation list to attach plugin detail to provider cards', () => {
it('should use the summary plugin map to attach plugin metadata to provider cards', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
plugin_id: 'langgenius/openai-marketplace',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
@@ -542,25 +580,23 @@ describe('ModelProviderPage', () => {
quota_configurations: [mockQuotaConfig],
},
})
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:1.0.0',
name: 'openai',
label: { en_US: 'OpenAI Plugin' } as unknown as PluginDeclaration['label'],
}),
}),
]
mockProviderContextState.modelProviderPlugins = {
'langgenius/openai-marketplace': {
installation_id: 'openai-installation',
plugin_id: 'langgenius/openai-marketplace',
plugin_unique_identifier: 'langgenius/openai:1.0.0',
runtime_type: 'local',
source: 'marketplace',
version: '1.0.0',
},
}
renderModelProviderPage()
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, {
category: PluginCategoryEnum.model,
})
expect(mockUseInstalledPluginList).not.toHaveBeenCalled()
expect(screen.getByTestId('provider-card')).toHaveAttribute(
'data-plugin-id',
'langgenius/openai',
'langgenius/openai-marketplace',
)
expect(screen.queryByText('OpenAI Plugin')).not.toBeInTheDocument()
})
@@ -587,24 +623,27 @@ describe('ModelProviderPage', () => {
).not.toBeInTheDocument()
})
it('should refresh model providers once when a debugging model plugin is missing from providers', () => {
mockInstalledModelPlugins.value = [
createPluginDetail({
it('should not refresh providers when remote plugin metadata already comes from summary', () => {
mockProviderContextState.modelProviderPlugins = {
'langgenius/debug-model': {
installation_id: 'debug-installation',
plugin_id: 'langgenius/debug-model',
declaration: createPluginDeclaration({
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
}),
}),
]
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
runtime_type: 'remote',
source: 'remote',
version: '1.0.0',
},
}
renderModelProviderPage()
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
})
it('should prefer debugging plugin detail when an installed model plugin shares the same plugin id', () => {
it('should render remote source from the authoritative summary plugin entry', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
plugin_id: 'langgenius/openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
@@ -613,26 +652,16 @@ describe('ModelProviderPage', () => {
quota_configurations: [mockQuotaConfig],
},
})
mockInstalledModelPlugins.value = [
createPluginDetail({
mockProviderContextState.modelProviderPlugins = {
'langgenius/openai': {
installation_id: 'openai-debug-installation',
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:debug',
name: 'openai',
label: { en_US: 'OpenAI Debug Plugin' } as unknown as PluginDeclaration['label'],
}),
source: PluginSource.debugging,
}),
createPluginDetail({
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:1.0.0',
name: 'openai',
label: { en_US: 'OpenAI Installed Plugin' } as unknown as PluginDeclaration['label'],
}),
source: PluginSource.marketplace,
}),
]
plugin_unique_identifier: 'langgenius/openai:debug',
runtime_type: 'remote',
source: 'remote',
version: '1.0.0',
},
}
renderModelProviderPage()
@@ -640,18 +669,17 @@ describe('ModelProviderPage', () => {
'data-plugin-id',
'langgenius/openai',
)
expect(screen.getByTestId('provider-card')).toHaveAttribute(
'data-plugin-source',
PluginSource.debugging,
)
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-source', 'remote')
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
})
it('should show provider placeholders while model providers are loading', () => {
mockProviderContextState.isLoadingModelProviders = true
mockProviderContextState.isSuccessModelProviders = false
renderModelProviderPage()
expect(mockUseInstalledPluginList).not.toHaveBeenCalled()
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
expect(screen.queryByTestId('provider-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
@@ -857,6 +885,7 @@ describe('ModelProviderPage', () => {
},
{
provider: 'langgenius/debug-model/debug-model',
plugin_id: 'langgenius/debug-model',
label: { en_US: 'Debug Model' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
@@ -866,16 +895,16 @@ describe('ModelProviderPage', () => {
},
},
)
mockInstalledModelPlugins.value = [
createPluginDetail({
mockProviderContextState.modelProviderPlugins = {
'langgenius/debug-model': {
installation_id: 'debug-installation',
plugin_id: 'langgenius/debug-model',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
name: 'debug-model',
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
}),
}),
]
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
runtime_type: 'remote',
source: 'remote',
version: '1.0.0',
},
}
renderModelProviderPage()
@@ -1,14 +1,26 @@
import type { Mock } from 'vitest'
import type { ModelProvider } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getStepByStepTourTargetSelector,
STEP_BY_STEP_TOUR_TARGETS,
} from '@/app/components/step-by-step-tour/target-registry'
import { consoleQuery } from '@/service/client'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { useMarketplaceAllPlugins } from '../hooks'
import InstallFromMarketplace from '../install-from-marketplace'
const render = (ui: React.ReactElement) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(
consoleQuery.workspaces.current.plugin.installedIds.get.queryKey({
input: { query: { category: 'model' } },
}),
{ plugin_ids: [] },
)
return renderWithConsoleQuery(ui, { queryClient })
}
// Mock dependencies
vi.mock('@/next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
@@ -76,20 +88,53 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
}))
describe('InstallFromMarketplace', () => {
const mockProviders = [] as ModelProvider[]
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should wait until the section approaches the viewport before loading marketplace data', () => {
let intersectionCallback: IntersectionObserverCallback | undefined
const disconnect = vi.fn()
function MockIntersectionObserver(callback: IntersectionObserverCallback) {
intersectionCallback = callback
return {
disconnect,
observe: vi.fn(),
takeRecords: vi.fn(),
unobserve: vi.fn(),
root: null,
thresholds: [0],
}
}
vi.stubGlobal('IntersectionObserver', vi.fn(MockIntersectionObserver))
render(<InstallFromMarketplace searchText="" />)
expect(useMarketplaceAllPlugins).toHaveBeenLastCalledWith('', [], false)
act(() => {
intersectionCallback?.(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(useMarketplaceAllPlugins).toHaveBeenLastCalledWith('', [], true)
expect(disconnect).toHaveBeenCalled()
})
it('should render expanded by default', () => {
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
render(<InstallFromMarketplace searchText="" />)
expect(screen.getByText('common.modelProvider.installProvider')).toBeInTheDocument()
expect(screen.getByTestId('plugin-list')).toBeInTheDocument()
})
it('should collapse when clicked', () => {
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
render(<InstallFromMarketplace searchText="" />)
const toggle = screen.getByRole('button', { name: /common\.modelProvider\.installProvider/ })
fireEvent.click(toggle)
@@ -107,7 +152,7 @@ describe('InstallFromMarketplace', () => {
isLoading: true,
})
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
render(<InstallFromMarketplace searchText="" />)
// It's expanded by default, so loading should show immediately
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
@@ -118,7 +163,7 @@ describe('InstallFromMarketplace', () => {
isLoading: false,
})
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
render(<InstallFromMarketplace searchText="" />)
// Expanded by default
expect(screen.getByText('Plugin 1')).toBeInTheDocument()
})
@@ -136,7 +181,6 @@ describe('InstallFromMarketplace', () => {
render(
<InstallFromMarketplace
providers={mockProviders}
searchText=""
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall}
/>,
@@ -164,14 +208,14 @@ describe('InstallFromMarketplace', () => {
isLoading: false,
})
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
render(<InstallFromMarketplace searchText="" />)
expect(screen.getByText('Plugin 1')).toBeInTheDocument()
expect(screen.queryByText('Bundle 1')).not.toBeInTheDocument()
})
it('should render discovery link', () => {
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
render(<InstallFromMarketplace searchText="" />)
expect(screen.getByText('plugin.marketplace.difyMarketplace')).toHaveAttribute(
'href',
'https://marketplace.test/plugins/model?theme=light',
@@ -181,13 +225,7 @@ describe('InstallFromMarketplace', () => {
it('should use the marketplace callback action when provided', () => {
const onOpenMarketplace = vi.fn()
render(
<InstallFromMarketplace
providers={mockProviders}
searchText=""
onOpenMarketplace={onOpenMarketplace}
/>,
)
render(<InstallFromMarketplace searchText="" onOpenMarketplace={onOpenMarketplace} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.difyMarketplace' }))
@@ -4,7 +4,6 @@ import {
genModelNameFormSchema,
genModelTypeFormSchema,
modelTypeFormat,
providerToPluginId,
sizeFormat,
} from '../utils'
@@ -23,16 +22,6 @@ describe('utils', () => {
})
})
describe('providerToPluginId', () => {
it('should return the plugin id prefix when the provider key contains a provider segment', () => {
expect(providerToPluginId('langgenius/openai/openai')).toBe('langgenius/openai')
})
it('should return an empty string when the provider key has no plugin prefix', () => {
expect(providerToPluginId('openai')).toBe('')
})
})
describe('modelTypeFormat', () => {
it('should format text embedding type', () => {
expect(modelTypeFormat(ModelTypeEnum.textEmbedding)).toBe('TEXT EMBEDDING')
@@ -1,3 +1,4 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { Model, ModelItem, ModelProvider } from './declarations'
import type { CredentialPanelState } from './provider-added-card/use-credential-panel-state'
import { ModelStatusEnum } from './declarations'
@@ -28,7 +29,7 @@ export const DERIVED_MODEL_STATUS_TOOLTIP_I18N = {
export const deriveModelStatus = (
modelId: string | undefined,
providerName: string | undefined,
currentModelProvider: ModelProvider | Model | undefined,
currentModelProvider: ModelProvider | ModelProviderSummaryResponse | Model | undefined,
currentModel: ModelItem | undefined,
credentialState: CredentialPanelState,
): DerivedModelStatus => {
@@ -1,3 +1,4 @@
import type { ModelType } from '@dify/contracts/api/console/workspaces/types.gen'
import type {
ConfigurationMethodEnum,
Credential,
@@ -10,6 +11,7 @@ import type {
ModelProvider,
ModelTypeEnum,
} from './declarations'
import type { ModelModalType } from '@/context/modal-context'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
@@ -22,7 +24,7 @@ import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { fetchDefaultModal, fetchModelList } from '@/service/common'
import { commonQueryKeys } from '@/service/use-common'
import { commonQueryKeys, modelProviderDetailsQueryOptions } from '@/service/use-common'
import { useExpandModelProviderList } from './atoms'
import { CustomConfigurationStatusEnum, ModelStatusEnum } from './declarations'
@@ -74,10 +76,16 @@ export const useLanguage = () => {
const locale = useLocale()
return locale.replace('-', '_')
}
export const useModelList = (type: ModelTypeEnum) => {
type UseModelListOptions = {
enabled?: boolean
}
export const useModelList = (type: ModelTypeEnum, { enabled = true }: UseModelListOptions = {}) => {
const { data, refetch, isPending } = useQuery({
queryKey: commonQueryKeys.modelList(type),
queryFn: () => fetchModelList(`/workspaces/current/models/model-types/${type}`),
enabled,
})
return {
@@ -100,7 +108,7 @@ export const useDefaultModel = (type: ModelTypeEnum) => {
}
}
export const useCurrentProviderAndModel = (modelList: Model[], defaultModel?: DefaultModel) => {
export const getCurrentProviderAndModel = (modelList: Model[], defaultModel?: DefaultModel) => {
const currentProvider = modelList.find((provider) => provider.provider === defaultModel?.provider)
const currentModel = currentProvider?.models.find((model) => model.model === defaultModel?.model)
@@ -110,6 +118,8 @@ export const useCurrentProviderAndModel = (modelList: Model[], defaultModel?: De
}
}
export { getCurrentProviderAndModel as useCurrentProviderAndModel }
export const useTextGenerationCurrentProviderAndModelAndModelList = (
defaultModel?: DefaultModel,
) => {
@@ -117,7 +127,7 @@ export const useTextGenerationCurrentProviderAndModelAndModelList = (
const activeTextGenerationModelList = textGenerationModelList.filter(
(model) => model.status === ModelStatusEnum.active,
)
const { currentProvider, currentModel } = useCurrentProviderAndModel(
const { currentProvider, currentModel } = getCurrentProviderAndModel(
textGenerationModelList,
defaultModel,
)
@@ -142,7 +152,7 @@ export const useModelListAndDefaultModel = (type: ModelTypeEnum) => {
export const useModelListAndDefaultModelAndCurrentProviderAndModel = (type: ModelTypeEnum) => {
const { modelList, defaultModel } = useModelListAndDefaultModel(type)
const { currentProvider, currentModel } = useCurrentProviderAndModel(modelList, {
const { currentProvider, currentModel } = getCurrentProviderAndModel(modelList, {
provider: defaultModel?.provider.provider || '',
model: defaultModel?.model || '',
})
@@ -159,7 +169,7 @@ export const useUpdateModelList = () => {
const queryClient = useQueryClient()
const updateModelList = useCallback(
(type: ModelTypeEnum) => {
(type: ModelTypeEnum | ModelType) => {
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelList(type) })
},
[queryClient],
@@ -182,20 +192,48 @@ export const useUpdateModelProviders = () => {
const queryClient = useQueryClient()
const updateModelProviders = useCallback(() => {
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviders })
queryClient.invalidateQueries({
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
})
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails })
}, [queryClient])
return updateModelProviders
}
export const useLazyModelProviderDetail = (providerName: string) => {
const [enabled, setEnabled] = useState(false)
const queryClient = useQueryClient()
const { data, isFetching } = useQuery({
...modelProviderDetailsQueryOptions(),
enabled,
})
const providerDetail = data?.data.find((provider) => provider.provider === providerName)
const loadProviderDetail = useCallback(async () => {
setEnabled(true)
try {
const response = await queryClient.fetchQuery(modelProviderDetailsQueryOptions())
return response.data.find((provider) => provider.provider === providerName)
} catch {
return undefined
}
}, [providerName, queryClient])
return {
providerDetail,
loadProviderDetail,
isProviderDetailEnabled: enabled,
isLoadingProviderDetail: enabled && isFetching,
}
}
export const useMarketplaceAllPlugins = (
providers: ModelProvider[],
searchText: string,
installedPluginIds: string[],
enabled = true,
) => {
const exclude = useMemo(() => {
return providers.map((provider) => provider.provider.replace(/(.+)\/([^/]+)$/, '$1'))
}, [providers])
const exclude = installedPluginIds
const { plugins: collectionPlugins = [], isLoading: isCollectionLoading } =
useMarketplacePluginsByCollectionId(enabled ? '__model-settings-pinned-models' : undefined)
const {
@@ -203,14 +241,12 @@ export const useMarketplaceAllPlugins = (
queryPlugins,
queryPluginsWithDebounced,
cancelQueryPluginsWithDebounced = () => {},
resetPlugins = () => {},
isLoading: isPluginsLoading,
} = useMarketplacePlugins()
} = useMarketplacePlugins(enabled)
useEffect(() => {
if (!enabled) {
cancelQueryPluginsWithDebounced()
resetPlugins()
return
}
@@ -239,7 +275,6 @@ export const useMarketplaceAllPlugins = (
enabled,
queryPlugins,
queryPluginsWithDebounced,
resetPlugins,
searchText,
exclude,
])
@@ -253,7 +288,11 @@ export const useMarketplaceAllPlugins = (
for (let i = 0; i < plugins.length; i++) {
const plugin = plugins[i]
if (plugin!.type !== 'bundle' && !allPlugins.find((p) => p.plugin_id === plugin!.plugin_id))
if (
!exclude.includes(plugin!.plugin_id) &&
plugin!.type !== 'bundle' &&
!allPlugins.find((p) => p.plugin_id === plugin!.plugin_id)
)
allPlugins.push(plugin!)
}
}
@@ -262,7 +301,10 @@ export const useMarketplaceAllPlugins = (
}, [enabled, plugins, collectionPlugins, exclude])
return {
plugins: enabled && searchText ? plugins : allPlugins,
plugins:
enabled && searchText
? plugins?.filter((plugin) => !exclude.includes(plugin.plugin_id))
: allPlugins,
isLoading: enabled && (isCollectionLoading || isPluginsLoading),
}
}
@@ -332,7 +374,7 @@ export const useModelModalHandler = () => {
isModelCredential?: boolean
credential?: Credential
model?: CustomModel
onUpdate?: (newPayload: any, formValues?: Record<string, any>) => void
onUpdate?: (newPayload?: ModelModalType, formValues?: Record<string, unknown>) => void
mode?: ModelModalModeEnum
} = {},
) => {
@@ -1,20 +1,21 @@
import type {
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import type { ModelProvider } from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useEffect, useMemo, useRef } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useInstalledPluginList } from '@/service/use-plugins'
import { consoleQuery } from '@/service/client'
import UpdateSettingDialog from '../update-setting-dialog'
import { CustomConfigurationStatusEnum, ModelTypeEnum } from './declarations'
import { ModelTypeEnum } from './declarations'
import { useDefaultModel } from './hooks'
import ModelProviderPageBody from './model-provider-page-body'
import SystemModelSelector from './system-model-selector'
@@ -36,6 +37,11 @@ type Props = Readonly<{
const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/anthropic']
export type ModelProviderPluginSummary = ModelProviderPluginSummaryResponse & {
latestVersion?: string
latestUniqueIdentifier?: string
}
const ModelProviderPage = ({
layout,
onOpenMarketplace,
@@ -61,44 +67,36 @@ const ModelProviderPage = ({
)
const {
modelProviders: providers,
modelProviderPlugins = {},
isLoadingModelProviders,
refreshModelProviders,
} = useProviderContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: installedModelPlugins } = useInstalledPluginList(false, 100, {
category: PluginCategoryEnum.model,
})
const enrichedPlugins = usePluginsWithLatestVersion(installedModelPlugins?.plugins)
const pluginDetailMap = useMemo(() => {
const map = new Map<string, PluginDetail>()
for (const plugin of enrichedPlugins) {
const existingPlugin = map.get(plugin.plugin_id)
if (!existingPlugin || plugin.source === PluginSource.debugging)
map.set(plugin.plugin_id, plugin)
const marketplacePluginIds = useMemo(
() =>
Object.values(modelProviderPlugins)
.filter((plugin) => plugin.source === 'marketplace')
.map((plugin) => plugin.plugin_id),
[modelProviderPlugins],
)
const { data: latestVersionData } = useQuery(
consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions({
input: { body: { plugin_ids: marketplacePluginIds } },
enabled: !!marketplacePluginIds.length,
}),
)
const pluginSummaryMap = useMemo(() => {
const map = new Map<string, ModelProviderPluginSummary>()
for (const plugin of Object.values(modelProviderPlugins)) {
const latestVersion = latestVersionData?.versions[plugin.plugin_id]
map.set(plugin.plugin_id, {
...plugin,
latestVersion: latestVersion?.version,
latestUniqueIdentifier: latestVersion?.unique_identifier,
})
}
return map
}, [enrichedPlugins])
const debuggingModelPluginKey = useMemo(() => {
const debuggingModelPluginIds = enrichedPlugins
.filter((plugin) => plugin.source === PluginSource.debugging)
.map((plugin) => `${plugin.plugin_id}:${plugin.plugin_unique_identifier}`)
.sort()
return debuggingModelPluginIds.join(',')
}, [enrichedPlugins])
const refreshedDebuggingModelPluginKeyRef = useRef('')
useEffect(() => {
if (!debuggingModelPluginKey) {
refreshedDebuggingModelPluginKeyRef.current = ''
return
}
if (refreshedDebuggingModelPluginKeyRef.current === debuggingModelPluginKey) return
refreshedDebuggingModelPluginKeyRef.current = debuggingModelPluginKey
refreshModelProviders?.()
}, [debuggingModelPluginKey, refreshModelProviders])
}, [latestVersionData, modelProviderPlugins])
const enableMarketplace = systemFeatures.enable_marketplace
const isDefaultModelLoading =
isTextGenerationDefaultModelLoading ||
@@ -107,17 +105,11 @@ const ModelProviderPage = ({
isSpeech2textDefaultModelLoading ||
isTTSDefaultModelLoading
const [configuredProviders, notConfiguredProviders] = useMemo(() => {
const configuredProviders: ModelProvider[] = []
const notConfiguredProviders: ModelProvider[] = []
const configuredProviders: ModelProviderSummaryResponse[] = []
const notConfiguredProviders: ModelProviderSummaryResponse[] = []
providers.forEach((provider) => {
if (
provider.custom_configuration.status === CustomConfigurationStatusEnum.active ||
(provider.system_configuration.enabled === true &&
provider.system_configuration.quota_configurations.some(
(item) => item.quota_type === provider.system_configuration.current_quota_type,
))
) {
if (provider.is_configured) {
configuredProviders.push(provider)
} else {
notConfiguredProviders.push(provider)
@@ -183,14 +175,14 @@ const ModelProviderPage = ({
(provider) =>
provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase()) ||
Object.values(provider.label).some((text) =>
text.toLowerCase().includes(debouncedSearchText.toLowerCase()),
text?.toLowerCase().includes(debouncedSearchText.toLowerCase()),
),
)
const filteredNotConfiguredProviders = notConfiguredProviders.filter(
(provider) =>
provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase()) ||
Object.values(provider.label).some((text) =>
text.toLowerCase().includes(debouncedSearchText.toLowerCase()),
text?.toLowerCase().includes(debouncedSearchText.toLowerCase()),
),
)
@@ -257,7 +249,7 @@ const ModelProviderPage = ({
showMarketplace={showMarketplace}
enableMarketplace={enableMarketplace}
searchText={searchText}
pluginDetailMap={pluginDetailMap}
pluginSummaryMap={pluginSummaryMap}
onOpenMarketplace={onOpenMarketplace}
/>
)
@@ -1,8 +1,8 @@
import type { ModelProvider } from './declarations'
import type { Plugin } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Loading from '@/app/components/base/loading'
@@ -12,17 +12,16 @@ import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/us
import ProviderCard from '@/app/components/plugins/provider-card'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { useMarketplaceAllPlugins } from './hooks'
type InstallFromMarketplaceProps = {
onOpenMarketplace?: () => void
providers: ModelProvider[]
searchText: string
stepByStepTourTarget?: string
}
const InstallFromMarketplace = ({
onOpenMarketplace,
providers,
searchText,
stepByStepTourTarget,
}: InstallFromMarketplaceProps) => {
@@ -30,10 +29,44 @@ const InstallFromMarketplace = ({
const { theme } = useTheme()
const { canInstallPlugin } = usePluginSettingsAccess()
const [collapse, setCollapse] = useState(false)
const { plugins: allPlugins, isLoading: isAllPluginsLoading } = useMarketplaceAllPlugins(
providers,
searchText,
const [hasEnteredViewport, setHasEnteredViewport] = useState(
() => !globalThis.IntersectionObserver,
)
const [hasBeenReopened, setHasBeenReopened] = useState(false)
const sectionRef = useRef<HTMLDivElement>(null)
const shouldLoadMarketplace = !collapse && (hasEnteredViewport || !!searchText || hasBeenReopened)
const { data: installedPluginIds, isSuccess: hasLoadedInstalledPluginIds } = useQuery({
...consoleQuery.workspaces.current.plugin.installedIds.get.queryOptions({
input: { query: { category: 'model' } },
enabled: shouldLoadMarketplace,
}),
select: (data) => data.plugin_ids,
})
const { plugins: allPlugins, isLoading: isAllPluginsLoading } = useMarketplaceAllPlugins(
searchText,
installedPluginIds ?? [],
shouldLoadMarketplace && hasLoadedInstalledPluginIds,
)
useEffect(() => {
const section = sectionRef.current
if (!section || hasEnteredViewport) return
const observer = new IntersectionObserver(([entry]) => {
if (!entry?.isIntersecting) return
setHasEnteredViewport(true)
observer.disconnect()
})
observer.observe(section)
return () => observer.disconnect()
}, [hasEnteredViewport])
const handleToggle = () => {
setCollapse((previous) => {
if (previous) setHasBeenReopened(true)
return !previous
})
}
const cardRender = useCallback((plugin: Plugin) => {
if (plugin.type === 'bundle') return null
@@ -42,7 +75,11 @@ const InstallFromMarketplace = ({
}, [])
return (
<div id="model-provider-marketplace" className="flex scroll-mt-4 flex-col gap-2">
<div
ref={sectionRef}
id="model-provider-marketplace"
className="flex scroll-mt-4 flex-col gap-2"
>
<Divider className="my-2! h-px" />
<div className="relative flex flex-col gap-2">
<div
@@ -54,7 +91,7 @@ const InstallFromMarketplace = ({
<button
type="button"
className="flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-left system-md-semibold text-text-primary"
onClick={() => setCollapse((prev) => !prev)}
onClick={handleToggle}
aria-expanded={!collapse}
>
<span className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} />
@@ -86,8 +123,11 @@ const InstallFromMarketplace = ({
)}
</div>
</div>
{!collapse && isAllPluginsLoading && <Loading type="area" />}
{!isAllPluginsLoading && !collapse && (
{!collapse && shouldLoadMarketplace && !hasLoadedInstalledPluginIds && (
<Loading type="area" />
)}
{!collapse && hasLoadedInstalledPluginIds && isAllPluginsLoading && <Loading type="area" />}
{!isAllPluginsLoading && !collapse && hasLoadedInstalledPluginIds && (
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
@@ -0,0 +1,22 @@
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import ConfigModel from '../config-model'
describe('ConfigModel', () => {
it.each([
{
name: 'common.operation.config',
props: {},
},
{
name: 'common.modelProvider.auth.authorizationError',
props: { loadBalancingInvalid: true },
},
])('announces loading for the $name action', ({ name, props }) => {
render(<ConfigModel {...props} loading />)
const action = screen.getByRole('button', { name })
expect(action).toHaveAttribute('aria-busy', 'true')
expect(action).toHaveAttribute('aria-disabled', 'true')
})
})
@@ -1,5 +1,6 @@
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ManageCustomModelCredentials from '../manage-custom-model-credentials'
// Mock hooks
@@ -17,6 +18,8 @@ vi.mock('../authorized', () => ({
renderTrigger,
items,
popupTitle,
isOpen,
onOpenChange,
}: {
renderTrigger: (o?: boolean) => React.ReactNode
items: Array<{
@@ -24,12 +27,17 @@ vi.mock('../authorized', () => ({
selectedCredential?: { credential_id?: string }
}>
popupTitle: string
isOpen?: boolean
onOpenChange?: (open: boolean) => void
}) => (
<div data-testid="authorized-mock">
<div data-testid="trigger-closed">{renderTrigger()}</div>
<div data-testid="trigger-open">{renderTrigger(true)}</div>
<div data-testid="popup-title">{popupTitle}</div>
<div data-testid="items-count">{items.length}</div>
<button type="button" data-open={isOpen} onClick={() => onOpenChange?.(false)}>
close
</button>
<div data-testid="items-selected">
{items.map((item, index) => (
<span
@@ -103,6 +111,21 @@ describe('ManageCustomModelCredentials', () => {
expect(screen.getByTestId('trigger-open')).toBeInTheDocument()
})
it('should forward controlled open state', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
mockUseCustomModels.mockReturnValue([{ model: 'gpt-4' }])
render(
<ManageCustomModelCredentials provider={mockProvider} isOpen onOpenChange={onOpenChange} />,
)
const closeButton = screen.getByRole('button', { name: 'close' })
expect(closeButton).toHaveAttribute('data-open', 'true')
await user.click(closeButton)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('should pass undefined selectedCredential when model has no current_credential_id', () => {
const mockModels = [
{
@@ -26,9 +26,16 @@ const AddCustomModel = ({
provider,
configurationMethod,
currentCustomConfigurationModelFixedFields,
open: controlledOpen,
onOpenChange,
}: AddCustomModelProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [localOpen, setLocalOpen] = useState(false)
const open = controlledOpen ?? localOpen
const setOpen = (nextOpen: boolean) => {
setLocalOpen(nextOpen)
onOpenChange?.(nextOpen)
}
const canAddedModels = useCanAddedModels(provider)
const noModels = !canAddedModels.length
const { canUseCredential, canCreateCredential } = useCredentialPermissions()
@@ -7,12 +7,16 @@ import { useTranslation } from 'react-i18next'
type ConfigModelProps = {
onClick?: () => void
loading?: boolean
disabled?: boolean
loadBalancingEnabled?: boolean
loadBalancingInvalid?: boolean
credentialRemoved?: boolean
}
const ConfigModel = ({
onClick,
loading,
disabled,
loadBalancingEnabled,
loadBalancingInvalid,
credentialRemoved,
@@ -21,14 +25,19 @@ const ConfigModel = ({
if (loadBalancingInvalid) {
return (
<div
className="relative flex h-4.5 cursor-pointer items-center rounded-[5px] border border-text-warning bg-components-badge-bg-dimm px-1.5 system-2xs-medium-uppercase text-text-warning"
<Button
variant="ghost"
size="small"
loading={loading}
disabled={disabled}
aria-busy={loading || undefined}
className="relative h-4.5 rounded-[5px] border border-text-warning bg-components-badge-bg-dimm px-1.5 system-2xs-medium-uppercase text-text-warning shadow-none hover:bg-components-badge-bg-dimm"
onClick={onClick}
>
<RiScales3Line className="mr-0.5 size-3" />
{t(($) => $['modelProvider.auth.authorizationError'], { ns: 'common' })}
<StatusDot status="warning" className="absolute -top-px -right-px size-1.5" />
</div>
</Button>
)
}
@@ -36,6 +45,9 @@ const ConfigModel = ({
<Button
variant="secondary"
size="small"
loading={loading}
disabled={disabled}
aria-busy={loading || undefined}
className={cn('hidden shrink-0 group-hover:flex', credentialRemoved && 'flex')}
onClick={onClick}
>
@@ -1,3 +1,4 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelProvider } from '../../../declarations'
import { renderHook } from '@testing-library/react'
import { useCredentialStatus } from '../use-credential-status'
@@ -48,6 +49,28 @@ describe('useCredentialStatus', () => {
expect(restrictedRes.current.notAllowedToUse).toBe(true)
})
it('uses the summary credential usability flag', () => {
const provider = {
custom_configuration: {
current_credential_id: '123',
current_credential_name: 'Key',
current_credential_usable: false,
available_credentials: [
{
credential_id: '123',
credential_name: 'Key',
},
],
},
} as unknown as ModelProviderSummaryResponse
const { result } = renderHook(() => useCredentialStatus(provider))
expect(result.current.hasCredential).toBe(true)
expect(result.current.authorized).toBe(false)
expect(result.current.notAllowedToUse).toBe(true)
})
it('handles undefined custom configuration gracefully', () => {
const { result } = renderHook(() =>
useCredentialStatus({ custom_configuration: {} } as ModelProvider),
@@ -1,7 +1,10 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelProvider } from '../../declarations'
import { useMemo } from 'react'
export const useCredentialStatus = (provider: ModelProvider | undefined) => {
export const useCredentialStatus = (
provider: ModelProvider | ModelProviderSummaryResponse | undefined,
) => {
const { current_credential_id, current_credential_name, available_credentials } =
provider?.custom_configuration ?? {}
const hasCredential = !!available_credentials?.length
@@ -9,8 +12,20 @@ export const useCredentialStatus = (provider: ModelProvider | undefined) => {
const currentCredential = available_credentials?.find(
(credential) => credential.credential_id === current_credential_id,
)
const notAllowedToUse = currentCredential?.not_allowed_to_use
const authorized = !!(current_credential_id && current_credential_name && !notAllowedToUse)
const summaryCredentialUsable =
provider && 'current_credential_usable' in provider.custom_configuration
? provider.custom_configuration.current_credential_usable
: undefined
const notAllowedToUse =
summaryCredentialUsable === false
? true
: currentCredential && 'not_allowed_to_use' in currentCredential
? currentCredential.not_allowed_to_use
: undefined
const authorized =
summaryCredentialUsable !== undefined
? summaryCredentialUsable
: !!(current_credential_id && current_credential_name && !notAllowedToUse)
return useMemo(
() => ({
@@ -18,7 +33,7 @@ export const useCredentialStatus = (provider: ModelProvider | undefined) => {
authorized,
authRemoved,
current_credential_id,
current_credential_name,
current_credential_name: current_credential_name ?? undefined,
available_credentials,
notAllowedToUse,
}),
@@ -16,10 +16,14 @@ import { useCustomModels } from './hooks'
type ManageCustomModelCredentialsProps = {
provider: ModelProvider
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
isOpen?: boolean
onOpenChange?: (open: boolean) => void
}
const ManageCustomModelCredentials = ({
provider,
currentCustomConfigurationModelFixedFields,
isOpen,
onOpenChange,
}: ManageCustomModelCredentialsProps) => {
const { t } = useTranslation()
const customModels = useCustomModels(provider)
@@ -59,6 +63,8 @@ const ManageCustomModelCredentials = ({
: undefined,
}))}
renderTrigger={renderTrigger}
isOpen={isOpen}
onOpenChange={onOpenChange}
authParams={{
isModelCredential: true,
mode: ModelModalModeEnum.configModelCredential,
@@ -1,5 +1,7 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { FC } from 'react'
import type { Model, ModelProvider } from '../declarations'
import type { ModelSelectorProvider } from '../model-selector/types'
import { cn } from '@langgenius/dify-ui/cn'
import { OpenaiYellow } from '@/app/components/base/icons/src/public/llm'
import useTheme from '@/hooks/use-theme'
@@ -8,7 +10,7 @@ import { Theme } from '@/types/app'
import { useLanguage } from '../hooks'
type ModelIconProps = {
provider?: Model | ModelProvider
provider?: Model | ModelProvider | ModelProviderSummaryResponse | ModelSelectorProvider
modelName?: string
className?: string
iconClassName?: string
@@ -49,7 +51,15 @@ const ModelIcon: FC<ModelIconProps> = ({
className,
)}
>
<img alt="model-icon" src={iconUrl} className={iconClassName} />
<img
alt=""
className={iconClassName}
decoding="async"
height={20}
loading="lazy"
src={iconUrl}
width={20}
/>
</div>
)
}
@@ -1,6 +1,6 @@
import type { MouseEvent } from 'react'
import type { ModelProvider } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import {
@@ -19,6 +19,8 @@ const invalidateInstalledPluginList = vi.fn()
const handleOpenModal = vi.fn()
const updateModelProviders = vi.fn()
const updateModelList = vi.fn()
const loadProviderDetail = vi.fn()
let isLoadingProviderDetail = false
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
@@ -33,6 +35,10 @@ vi.mock('@/service/use-plugins', () => ({
}))
vi.mock('../../hooks', () => ({
useLazyModelProviderDetail: () => ({
loadProviderDetail,
isLoadingProviderDetail,
}),
useModelModalHandler: () => handleOpenModal,
useUpdateModelList: () => updateModelList,
useUpdateModelProviders: () => updateModelProviders,
@@ -76,6 +82,7 @@ describe('AgentModelTrigger', () => {
pluginInfo = null
pluginLoading = false
inModelList = true
isLoadingProviderDetail = false
})
it('should render loading state when plugin info is still fetching', () => {
@@ -127,6 +134,14 @@ describe('AgentModelTrigger', () => {
expect(invalidateInstalledPluginList).toHaveBeenCalledWith(PluginCategoryEnum.model)
})
it('should not render the install action when the plugin has no package identifier', () => {
pluginInfo = { latest_package_identifier: '' }
render(<AgentModelTrigger modelId="gpt-4" providerName="openai" />)
expect(screen.queryByText('Install Plugin')).not.toBeInTheDocument()
})
it('should show configuration action when provider requires setup', () => {
modelProviders = [
{
@@ -145,6 +160,19 @@ describe('AgentModelTrigger', () => {
expect(screen.getByText('workflow.nodes.agent.notAuthorized')).toBeInTheDocument()
})
it('should load complete provider detail before opening configuration', async () => {
const providerDetail = { provider: 'openai' } as ModelProvider
modelProviders = [{ provider: 'openai', is_configured: false }] as unknown as ModelProvider[]
loadProviderDetail.mockResolvedValue(providerDetail)
render(<AgentModelTrigger modelId="gpt-4" providerName="openai" />)
fireEvent.click(screen.getByRole('button', { name: /notAuthorized/ }))
await waitFor(() => {
expect(handleOpenModal).toHaveBeenCalledWith(providerDetail, 'predefined-model', undefined)
})
})
it('should render unconfigured state when model is not selected', () => {
render(<AgentModelTrigger />)
expect(screen.getByText('workflow.nodes.agent.configureModel')).toBeInTheDocument()
@@ -6,15 +6,12 @@ import { withSelectorKey } from '@/test/i18n-mock'
import StatusIndicators from '../status-indicators'
let installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
const { mockUseInstalledPluginList } = vi.hoisted(() => ({
mockUseInstalledPluginList: vi.fn(),
const mockUseInstalledPluginList = vi.fn((_options: unknown) => ({
data: { plugins: installedPlugins },
}))
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: (...args: unknown[]) => {
mockUseInstalledPluginList(...args)
return { data: { plugins: installedPlugins } }
},
useInstalledPluginList: (options: unknown) => mockUseInstalledPluginList(options),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
@@ -29,23 +26,7 @@ describe('StatusIndicators', () => {
beforeEach(() => {
vi.clearAllMocks()
installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
})
it('reads the model-specific installed plugin list', () => {
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'demo-plugin' }}
t={t}
/>,
)
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, {
category: PluginCategoryEnum.model,
})
mockUseInstalledPluginList.mockReturnValue({ data: { plugins: installedPlugins } })
})
const getPopoverTrigger = (name: string) => {
@@ -66,6 +47,10 @@ describe('StatusIndicators', () => {
/>,
)
expect(container).toBeEmptyDOMElement()
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
category: PluginCategoryEnum.model,
enabled: false,
})
})
it('should render deprecated tooltip when provider model is disabled and in model list', async () => {
@@ -80,6 +65,10 @@ describe('StatusIndicators', () => {
t={t}
/>,
)
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
category: PluginCategoryEnum.model,
enabled: false,
})
await user.hover(getPopoverTrigger('nodes.agent.modelSelectorTooltips.deprecated'))
@@ -100,6 +89,10 @@ describe('StatusIndicators', () => {
t={t}
/>,
)
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
category: PluginCategoryEnum.model,
enabled: false,
})
await user.hover(getPopoverTrigger('nodes.agent.modelNotSupport.title'))
@@ -119,6 +112,10 @@ describe('StatusIndicators', () => {
)
expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument()
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
category: PluginCategoryEnum.model,
enabled: true,
})
})
it('should render nothing when needsConfiguration is true even with disabled and modelProvider', () => {
@@ -133,6 +130,10 @@ describe('StatusIndicators', () => {
/>,
)
expect(container).toBeEmptyDOMElement()
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
category: PluginCategoryEnum.model,
enabled: false,
})
})
it('should render SwitchVersion with empty identifier when plugin is not in installed list', () => {
@@ -13,8 +13,13 @@ import {
useModelInList,
usePluginInfo,
} from '@/service/use-plugins'
import { CustomConfigurationStatusEnum, ModelTypeEnum } from '../declarations'
import { useModelModalHandler, useUpdateModelList, useUpdateModelProviders } from '../hooks'
import { ConfigurationMethodEnum, ModelTypeEnum } from '../declarations'
import {
useLazyModelProviderDetail,
useModelModalHandler,
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import ModelIcon from '../model-icon'
import ConfigurationButton from './configuration-button'
import ModelDisplay from './model-display'
@@ -47,14 +52,7 @@ const AgentModelTrigger: FC<AgentModelTriggerProps> = ({
const updateModelList = useUpdateModelList()
const { modelProvider, needsConfiguration } = useMemo(() => {
const modelProvider = modelProviders.find((item) => item.provider === providerName)
const needsConfiguration =
modelProvider?.custom_configuration.status === CustomConfigurationStatusEnum.noConfigure &&
!(
modelProvider.system_configuration.enabled === true &&
modelProvider.system_configuration.quota_configurations.find(
(item) => item.quota_type === modelProvider.system_configuration.current_quota_type,
)
)
const needsConfiguration = modelProvider ? !modelProvider.is_configured : false
return {
modelProvider,
needsConfiguration,
@@ -63,10 +61,22 @@ const AgentModelTrigger: FC<AgentModelTriggerProps> = ({
const [installed, setInstalled] = useState(false)
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const handleOpenModal = useModelModalHandler()
const { loadProviderDetail, isLoadingProviderDetail } = useLazyModelProviderDetail(
providerName ?? '',
)
const { data: inModelList = false } = useModelInList(currentProvider, modelId)
const { data: pluginInfo, isLoading: isPluginLoading } = usePluginInfo(providerName)
const handleConfigure = async () => {
if (!providerName) return
const providerDetail = await loadProviderDetail()
if (!providerDetail) return
handleOpenModal(providerDetail, ConfigurationMethodEnum.predefinedModel, undefined)
}
if (modelId && isPluginLoading) return <Loading />
return (
@@ -85,7 +95,7 @@ const AgentModelTrigger: FC<AgentModelTriggerProps> = ({
/>
<ModelDisplay currentModel={currentModel} modelId={modelId} />
{needsConfiguration && (
<ConfigurationButton modelProvider={modelProvider} handleOpenModal={handleOpenModal} />
<ConfigurationButton loading={isLoadingProviderDetail} onConfigure={handleConfigure} />
)}
<StatusIndicators
needsConfiguration={needsConfiguration}
@@ -95,7 +105,7 @@ const AgentModelTrigger: FC<AgentModelTriggerProps> = ({
pluginInfo={pluginInfo}
t={translateWorkflow}
/>
{!installed && !modelProvider && pluginInfo && (
{!installed && !modelProvider && pluginInfo?.latest_package_identifier && (
<InstallPluginButton
onClick={(e) => e.stopPropagation()}
size="small"
@@ -1,21 +1,20 @@
import { Button } from '@langgenius/dify-ui/button'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useTranslation } from 'react-i18next'
import { ConfigurationMethodEnum } from '../declarations'
type ConfigurationButtonProps = {
modelProvider: any
handleOpenModal: any
loading: boolean
onConfigure: () => void
}
const ConfigurationButton = ({ modelProvider, handleOpenModal }: ConfigurationButtonProps) => {
const ConfigurationButton = ({ loading, onConfigure }: ConfigurationButtonProps) => {
const { t } = useTranslation()
return (
<Button
size="small"
loading={loading}
onClick={(e) => {
e.stopPropagation()
handleOpenModal(modelProvider, ConfigurationMethodEnum.predefinedModel, undefined)
onConfigure()
}}
>
<div className="flex items-center justify-center gap-1">
@@ -1,7 +1,6 @@
import type { SelectorParam } from 'i18next'
import type { ReactNode } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiErrorWarningFill } from '@remixicon/react'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version'
import Link from '@/next/link'
@@ -54,8 +53,11 @@ const StatusIndicators = ({
pluginInfo,
t,
}: StatusIndicatorsProps) => {
const { data: pluginList } = useInstalledPluginList(false, 100, {
const shouldLoadInstalledModelPlugins =
!needsConfiguration && modelProvider && disabled && !inModelList && !!pluginInfo
const { data: pluginList } = useInstalledPluginList({
category: PluginCategoryEnum.model,
enabled: shouldLoadInstalledModelPlugins,
})
const renderTooltipContent = (
title: string,
@@ -103,7 +105,7 @@ const StatusIndicators = ({
ns: 'workflow',
})}
>
<RiErrorWarningFill className="size-4 text-text-destructive" />
<span aria-hidden className="i-ri-error-warning-fill size-4 text-text-destructive" />
</StatusPopover>
) : !pluginInfo ? (
<StatusPopover
@@ -115,7 +117,7 @@ const StatusIndicators = ({
'/plugins',
)}
>
<RiErrorWarningFill className="size-4 text-text-destructive" />
<span aria-hidden className="i-ri-error-warning-fill size-4 text-text-destructive" />
</StatusPopover>
) : (
<SwitchPluginVersion
@@ -141,7 +143,7 @@ const StatusIndicators = ({
'/plugins',
)}
>
<RiErrorWarningFill className="size-4 text-text-destructive" />
<span aria-hidden className="i-ri-error-warning-fill size-4 text-text-destructive" />
</StatusPopover>
)}
</>
@@ -1,21 +1,19 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { FC } from 'react'
import type { ModelProvider } from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { ModelProviderPluginSummary } from './index'
import { useSuspenseQuery } from '@tanstack/react-query'
import { Trans, useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { PluginSource } from '@/app/components/plugins/types'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import InstallFromMarketplace from './install-from-marketplace'
import ProviderAddedCard from './provider-added-card'
import QuotaPanel from './provider-added-card/quota-panel'
import { providerToPluginId } from './utils'
type ModelProviderPageBodyProps = {
providers: ModelProvider[]
filteredConfiguredProviders: ModelProvider[]
filteredNotConfiguredProviders: ModelProvider[]
providers: ModelProviderSummaryResponse[]
filteredConfiguredProviders: ModelProviderSummaryResponse[]
filteredNotConfiguredProviders: ModelProviderSummaryResponse[]
isLoadingModelProviders: boolean
showEmptyProvider: boolean
showConfiguredProviders: boolean
@@ -23,7 +21,7 @@ type ModelProviderPageBodyProps = {
showMarketplace: boolean
enableMarketplace: boolean
searchText: string
pluginDetailMap: Map<string, PluginDetail>
pluginSummaryMap: Map<string, ModelProviderPluginSummary>
onOpenMarketplace?: () => void
}
@@ -107,26 +105,27 @@ function EmptyProviderState({
type ProviderCardListProps = {
firstCardTarget?: string
providers: ModelProvider[]
pluginDetailMap: Map<string, PluginDetail>
providers: ModelProviderSummaryResponse[]
pluginSummaryMap: Map<string, ModelProviderPluginSummary>
notConfigured?: boolean
}
function isDebuggingProvider(provider: ModelProvider, pluginDetailMap: Map<string, PluginDetail>) {
return (
pluginDetailMap.get(providerToPluginId(provider.provider))?.source === PluginSource.debugging
)
function isDebuggingProvider(
provider: ModelProviderSummaryResponse,
pluginSummaryMap: Map<string, ModelProviderPluginSummary>,
) {
return pluginSummaryMap.get(provider.plugin_id)?.source === 'remote'
}
function ProviderCardList({
firstCardTarget,
providers,
pluginDetailMap,
pluginSummaryMap,
notConfigured,
}: ProviderCardListProps) {
const sortedProviders = [...providers].sort((a, b) => {
const aIsDebuggingPlugin = isDebuggingProvider(a, pluginDetailMap)
const bIsDebuggingPlugin = isDebuggingProvider(b, pluginDetailMap)
const aIsDebuggingPlugin = isDebuggingProvider(a, pluginSummaryMap)
const bIsDebuggingPlugin = isDebuggingProvider(b, pluginSummaryMap)
if (aIsDebuggingPlugin === bIsDebuggingPlugin) return 0
@@ -136,7 +135,7 @@ function ProviderCardList({
return (
<div className="relative flex flex-col gap-2">
{sortedProviders.map((provider, index) => {
const pluginDetail = pluginDetailMap.get(providerToPluginId(provider.provider))
const pluginSummary = pluginSummaryMap.get(provider.plugin_id)
return (
<div
@@ -146,7 +145,7 @@ function ProviderCardList({
<ProviderAddedCard
notConfigured={notConfigured}
provider={provider}
pluginDetail={pluginDetail}
pluginSummary={pluginSummary}
/>
</div>
)
@@ -166,7 +165,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
showMarketplace,
enableMarketplace,
searchText,
pluginDetailMap,
pluginSummaryMap,
onOpenMarketplace,
}) => {
const { t } = useTranslation()
@@ -203,7 +202,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
<ProviderCardList
firstCardTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction}
providers={filteredConfiguredProviders}
pluginDetailMap={pluginDetailMap}
pluginSummaryMap={pluginSummaryMap}
/>
)}
{showNotConfiguredProviders && (
@@ -219,14 +218,13 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
}
providers={filteredNotConfiguredProviders}
notConfigured
pluginDetailMap={pluginDetailMap}
pluginSummaryMap={pluginSummaryMap}
/>
</div>
)}
{showMarketplace && (
<div>
<InstallFromMarketplace
providers={providers}
searchText={searchText}
onOpenMarketplace={onOpenMarketplace}
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall}
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import ModelSelector from '../index'
vi.mock('../../hooks', () => ({
useCurrentProviderAndModel: () => ({
getCurrentProviderAndModel: () => ({
currentProvider: undefined,
currentModel: undefined,
}),
@@ -3,9 +3,10 @@ import type { DefaultModel, Model, ModelItem } from '../../declarations'
import type { ModelSelectorPreviewPayload } from '../popup-item'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { commonQueryKeys } from '@/service/use-common'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
@@ -150,7 +151,11 @@ const createComboboxNode = (node: ReactElement, onValueChange = vi.fn()) => (
)
const renderWithCombobox = (node: ReactElement, onValueChange = vi.fn()) => {
return render(createComboboxNode(node, onValueChange))
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(commonQueryKeys.modelProviderDetails, {
data: [makeProvider()],
})
return renderWithConsoleQuery(createComboboxNode(node, onValueChange), { queryClient })
}
describe('PopupItem', () => {
@@ -295,7 +300,7 @@ describe('PopupItem', () => {
).toBeInTheDocument()
})
it('should open model modal when clicking add on unconfigured model', () => {
it('should open model modal when clicking add on unconfigured model', async () => {
const onValueChange = vi.fn()
const { rerender } = renderWithCombobox(
<PopupItem
@@ -312,7 +317,9 @@ describe('PopupItem', () => {
fireEvent.click(addButton)
expect(onValueChange).not.toHaveBeenCalled()
expect(mockSetShowModelModal).toHaveBeenCalled()
await waitFor(() => {
expect(mockSetShowModelModal).toHaveBeenCalled()
})
const call = mockSetShowModelModal.mock.calls[0]![0] as { onSaveCallback?: () => void }
call.onSaveCallback?.()
@@ -338,6 +345,9 @@ describe('PopupItem', () => {
)
fireEvent.click(screen.getByText('COMMON.OPERATION.ADD'))
await waitFor(() => {
expect(mockSetShowModelModal).toHaveBeenCalledTimes(2)
})
const call2 = mockSetShowModelModal.mock.calls.at(-1)?.[0] as
| { onSaveCallback?: () => void }
| undefined
@@ -495,18 +505,18 @@ describe('PopupItem', () => {
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/))!.toBeInTheDocument()
})
it('should close the dropdown through dropdown content callbacks', () => {
it('should close the dropdown through dropdown content callbacks', async () => {
const onHide = vi.fn()
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={onHide} />)
fireEvent.click(screen.getByRole('button', { name: /my-api-key/ }))
fireEvent.click(screen.getByRole('button', { name: 'close dropdown' }))
fireEvent.click(await screen.findByRole('button', { name: 'close dropdown' }))
expect(onHide).toHaveBeenCalled()
})
it('should keep the credential dropdown enabled for manage-only users', () => {
it('should keep the credential dropdown enabled for manage-only users', async () => {
mockWorkspacePermissionKeys.value = ['credential.manage']
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
@@ -517,6 +527,6 @@ describe('PopupItem', () => {
fireEvent.click(trigger)
expect(screen.getByRole('button', { name: 'close dropdown' })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'close dropdown' })).toBeInTheDocument()
})
})
@@ -1,5 +1,6 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactElement } from 'react'
import type { Model, ModelItem, ModelProvider } from '../../declarations'
import type { Model, ModelItem } from '../../declarations'
import type { PopupProps } from '../popup'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { fireEvent, screen, waitFor } from '@testing-library/react'
@@ -43,13 +44,8 @@ vi.mock('@/utils/tool-call', () => ({
supportFunctionCall: mockSupportFunctionCall,
}))
type MockMarketplacePlugin = {
plugin_id: string
latest_package_identifier: string
}
type MockContextProvider = Pick<
ModelProvider,
ModelProviderSummaryResponse,
| 'provider'
| 'label'
| 'icon_small'
@@ -58,13 +54,12 @@ type MockContextProvider = Pick<
| 'system_configuration'
>
const mockMarketplacePlugins = vi.hoisted(() => ({
current: [] as MockMarketplacePlugin[],
isLoading: false,
}))
const mockContextModelProviders = vi.hoisted(() => ({
current: [] as MockContextProvider[],
}))
const mockContextModelProviderPlugins = vi.hoisted(() => ({
current: {} as Record<string, { plugin_id: string }>,
}))
const mockTrialModels = vi.hoisted(() => ({
current: ['test-openai', 'test-anthropic'] as string[],
}))
@@ -73,10 +68,6 @@ vi.mock('../../hooks', async () => {
return {
...actual,
useLanguage: () => mockLanguage,
useMarketplaceAllPlugins: () => ({
plugins: mockMarketplacePlugins.current,
isLoading: mockMarketplacePlugins.isLoading,
}),
}
})
@@ -92,7 +83,10 @@ vi.mock('../popup-item', () => ({
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ modelProviders: mockContextModelProviders.current }),
useProviderContext: () => ({
modelProviders: mockContextModelProviders.current,
modelProviderPlugins: mockContextModelProviderPlugins.current,
}),
}))
type PopupTestProps = Omit<PopupProps, 'inputValue' | 'onInputValueChange'>
@@ -160,6 +154,12 @@ vi.mock('@/service/use-plugins', () => ({
useInstallPackageFromMarketPlace: () => ({ mutateAsync: mockInstallMutateAsync }),
}))
const mockFetchPluginInfoFromMarketPlace = vi.hoisted(() => vi.fn())
vi.mock('@/service/plugins', () => ({
fetchPluginInfoFromMarketPlace: (params: Record<string, string>) =>
mockFetchPluginInfoFromMarketPlace(params),
}))
const mockRefreshPluginList = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
default: () => ({ refreshPluginList: mockRefreshPluginList }),
@@ -240,9 +240,15 @@ describe('Popup', () => {
vi.clearAllMocks()
mockLanguage = 'en_US'
mockSupportFunctionCall.mockReturnValue(true)
mockMarketplacePlugins.current = []
mockMarketplacePlugins.isLoading = false
mockFetchPluginInfoFromMarketPlace.mockResolvedValue({
data: {
plugin: {
latest_package_identifier: 'langgenius/openai:1.0.0',
},
},
})
mockContextModelProviders.current = []
mockContextModelProviderPlugins.current = {}
mockTrialModels.current = ['test-openai', 'test-anthropic']
mockSearchParams.current = new URLSearchParams()
Object.assign(mockTrialCredits, {
@@ -1001,6 +1007,52 @@ describe('Popup', () => {
)
})
it('should only mark API key fallback when the current credential is usable', () => {
Object.assign(mockTrialCredits, {
credits: 0,
totalCredits: 200,
isExhausted: true,
})
mockContextModelProviders.current = [
makeContextProvider({
provider: 'test-openai',
custom_configuration: {
status: 'active',
current_credential_usable: false,
} as MockContextProvider['custom_configuration'],
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
}),
]
const { rerender } = renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />)
expect(screen.getByTestId('credits-exhausted-alert')).toHaveAttribute(
'data-has-api-key-fallback',
'false',
)
mockContextModelProviders.current = [
makeContextProvider({
provider: 'test-openai',
custom_configuration: {
status: 'active',
current_credential_usable: true,
} as MockContextProvider['custom_configuration'],
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
}),
]
rerender(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />)
expect(screen.getByTestId('credits-exhausted-alert')).toHaveAttribute(
'data-has-api-key-fallback',
'true',
)
})
it('should not show credits exhausted alert when only non-trial system providers are exhausted', () => {
Object.assign(mockTrialCredits, {
credits: 0,
@@ -1105,6 +1157,9 @@ describe('Popup', () => {
it('should render marketplace providers that are not installed', () => {
mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })]
mockContextModelProviderPlugins.current = {
'langgenius/openai': { plugin_id: 'langgenius/openai' },
}
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
@@ -1148,6 +1203,9 @@ describe('Popup', () => {
} as MockContextProvider['system_configuration'],
}),
]
mockContextModelProviderPlugins.current = {
'langgenius/anthropic': { plugin_id: 'langgenius/anthropic' },
}
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
@@ -1169,6 +1227,9 @@ describe('Popup', () => {
} as MockContextProvider['system_configuration'],
}),
]
mockContextModelProviderPlugins.current = {
'langgenius/anthropic': { plugin_id: 'langgenius/anthropic' },
}
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
@@ -1191,10 +1252,18 @@ describe('Popup', () => {
expect(screen.getByText('TestOpenAI'))!.toBeInTheDocument()
})
it('should hide a marketplace provider when its plugin is already installed', () => {
mockContextModelProviderPlugins.current = {
'langgenius/openai': { plugin_id: 'langgenius/openai' },
}
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
expect(screen.getByText('TestAnthropic')).toBeInTheDocument()
})
it('should install plugin when clicking install button', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
@@ -1205,13 +1274,14 @@ describe('Popup', () => {
await waitFor(() => {
expect(mockInstallMutateAsync).toHaveBeenCalledWith('langgenius/openai:1.0.0')
})
expect(mockFetchPluginInfoFromMarketPlace).toHaveBeenCalledWith({
org: 'langgenius',
name: 'openai',
})
expect(mockRefreshPluginList).toHaveBeenCalled()
})
it('should handle install failure gracefully', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockRejectedValue(new Error('Install failed'))
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
@@ -1229,9 +1299,6 @@ describe('Popup', () => {
})
it('should run checkTaskStatus when not all_installed', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
mockCheck.mockResolvedValue(undefined)
@@ -1249,11 +1316,8 @@ describe('Popup', () => {
expect(mockRefreshPluginList).toHaveBeenCalled()
})
it('should skip install requests when marketplace plugins are still loading', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockMarketplacePlugins.isLoading = true
it('should skip install requests when the marketplace plugin lookup fails', async () => {
mockFetchPluginInfoFromMarketPlace.mockRejectedValue(new Error('Not found'))
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
@@ -1264,8 +1328,10 @@ describe('Popup', () => {
})
})
it('should skip install requests when the marketplace plugin cannot be found', async () => {
mockMarketplacePlugins.current = []
it('should skip install requests when the marketplace plugin has no package identifier', async () => {
mockFetchPluginInfoFromMarketPlace.mockResolvedValue({
data: { plugin: { latest_package_identifier: '' } },
})
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
@@ -6,7 +6,7 @@ import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ModelStatusEnum } from '../declarations'
import { useCurrentProviderAndModel } from '../hooks'
import { getCurrentProviderAndModel } from '../hooks'
import ModelSelectorTrigger from './model-selector-trigger'
import Popup from './popup'
import { getModelSelectorValueLabel, isSameModelSelectorValue } from './types'
@@ -58,7 +58,7 @@ function ModelSelector({
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const { currentProvider, currentModel } = useCurrentProviderAndModel(modelList, defaultModel)
const { currentProvider, currentModel } = getCurrentProviderAndModel(modelList, defaultModel)
const currentValue = useMemo<ModelSelectorValue | null>(() => {
if (!currentProvider || !currentModel) return null
@@ -10,7 +10,6 @@ type MarketplaceSectionProps = {
marketplaceProviders: ModelProviderQuotaGetPaid[]
marketplaceCollapsed: boolean
installingProvider: ModelProviderQuotaGetPaid | null
isMarketplacePluginsLoading: boolean
canInstallPlugin: boolean
theme?: string
onMarketplaceCollapsedChange: (collapsed: boolean) => void
@@ -22,7 +21,6 @@ function MarketplaceSection({
marketplaceProviders,
marketplaceCollapsed,
installingProvider,
isMarketplacePluginsLoading,
canInstallPlugin,
theme,
onMarketplaceCollapsedChange,
@@ -42,7 +40,7 @@ function MarketplaceSection({
<div className="flex h-5.5 items-center pr-2 pl-4">
<button
type="button"
className="flex flex-1 cursor-pointer items-center border-0 bg-transparent p-0 text-left system-sm-medium text-text-primary"
className="flex flex-1 cursor-pointer items-center border-0 bg-transparent p-0 text-left system-sm-medium text-text-primary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => onMarketplaceCollapsedChange(!marketplaceCollapsed)}
>
{t(($) => $['modelProvider.selector.fromMarketplace'], { ns: 'common' })}
@@ -78,7 +76,7 @@ function MarketplaceSection({
'shrink-0 backdrop-blur-[5px]',
!isInstalling && 'hidden group-hover:flex',
)}
disabled={isInstalling || isMarketplacePluginsLoading}
disabled={isInstalling}
onClick={() => onInstallPlugin(key)}
>
{isInstalling && (
@@ -95,7 +93,7 @@ function MarketplaceSection({
{onOpenMarketplace ? (
<button
type="button"
className="flex w-full cursor-pointer items-center gap-0.5 border-0 bg-transparent px-3 py-1.5 text-left"
className="flex w-full cursor-pointer items-center gap-0.5 border-0 bg-transparent px-3 py-1.5 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={onOpenMarketplace}
>
<span className="flex-1 system-xs-regular text-text-accent">
@@ -1,5 +1,6 @@
import type { DefaultModel, Model, ModelItem, TypeWithI18N } from '../declarations'
import type { ModelSelectorModelPredicate } from './types'
import type { I18nObject } from '@dify/contracts/api/console/workspaces/types.gen'
import type { DefaultModel, ModelItem } from '../declarations'
import type { ModelSelectorModelPredicate, ModelSelectorProvider } from './types'
import Fuse from 'fuse.js'
import { supportFunctionCall } from '@/utils/tool-call'
import { ModelFeatureEnum } from '../declarations'
@@ -29,7 +30,7 @@ type FilterModelSelectorModelsParams = {
aiCreditVisibleProviders: Set<string>
defaultModel?: DefaultModel
inputValue: string
installedModelList: Model[]
installedModelList: ModelSelectorProvider[]
modelPredicate?: ModelSelectorModelPredicate
scopeFeatures: ModelFeatureEnum[]
searchIndex: ModelSelectorSearchIndex
@@ -62,10 +63,12 @@ const normalizeModelSearchValue = (value: string) =>
const looksLikeModelQuery = (value: string) => /\d/.test(value)
const getLabelSearchValues = (label: TypeWithI18N, language: string) => {
if (label[language] !== undefined) return [label[language]]
const getLabelSearchValues = (label: I18nObject | null | undefined, language: string) => {
if (!label) return []
const localizedValue = label[language as keyof I18nObject]
if (localizedValue) return [localizedValue]
return Array.from(new Set(Object.values(label)))
return Array.from(new Set(Object.values(label).filter((value): value is string => !!value)))
}
const getProviderKeySearchValues = (provider: string) => {
@@ -87,7 +90,7 @@ const modelSupportsScopeFeatures = (modelItem: ModelItem, scopeFeatures: ModelFe
}
export const createModelSelectorSearchIndex = (
installedModelList: Model[],
installedModelList: ModelSelectorProvider[],
language: string,
): ModelSelectorSearchIndex => {
const providerEntries = installedModelList.map<ProviderSearchEntry>((model) => {
@@ -176,7 +179,7 @@ export const filterModelSelectorModels = ({
return { ...model, models: filteredModels }
})
.filter((model): model is Model => model !== null)
.filter((model): model is ModelSelectorProvider => model !== null)
if (defaultModel?.provider) {
filtered.sort((a, b) => {
@@ -1,6 +1,6 @@
import type { PreviewCardHandle } from '@langgenius/dify-ui/preview-card'
import type { DefaultModel, Model, ModelItem } from '../declarations'
import type { ModelSelectorModelPredicate } from './types'
import type { ComponentProps } from 'react'
import type { DefaultModel, ModelItem } from '../declarations'
import type { ModelSelectorModelPredicate, ModelSelectorProvider } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { ComboboxGroup, ComboboxItem, ComboboxItemIndicator } from '@langgenius/dify-ui/combobox'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
@@ -9,29 +9,36 @@ import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
import { renderI18nObject } from '@/i18n-config'
import { ConfigurationMethodEnum, ModelStatusEnum } from '../declarations'
import { useLanguage, useUpdateModelList, useUpdateModelProviders } from '../hooks'
import {
useLanguage,
useLazyModelProviderDetail,
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import DropdownContent from '../provider-added-card/model-auth-dropdown/dropdown-content'
import { useChangeProviderPriority } from '../provider-added-card/use-change-provider-priority'
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
import { useCredentialPanelState as useCredentialPanelInfo } from '../provider-added-card/use-credential-panel-state'
export type ModelSelectorPreviewPayload = {
provider: Model
provider: ModelSelectorProvider
modelItem: ModelItem
}
type PreviewCardHandle = NonNullable<ComponentProps<typeof PreviewCardTrigger>['handle']>
type PopupItemProps = {
defaultModel?: DefaultModel
model: Model
model: ModelSelectorProvider
modelPredicate?: ModelSelectorModelPredicate
modelSuggestionPredicate?: ModelSelectorModelPredicate
previewCardHandle: PreviewCardHandle<ModelSelectorPreviewPayload>
previewCardHandle: PreviewCardHandle
onPreviewCardClose: () => void
onHide: () => void
}
@@ -54,15 +61,18 @@ function PopupItem({
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const currentProvider = modelProviders.find((provider) => provider.provider === model.provider)
const { providerDetail, loadProviderDetail } = useLazyModelProviderDetail(model.provider)
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
const canOpenCredentialDropdown = canUseCredential || canCreateCredential || canManageCredential
const handleOpenModelModal = () => {
const handleOpenModelModal = async () => {
if (!canCreateCredential) return
if (!currentProvider) return
const detail = await loadProviderDetail()
if (!detail) return
setShowModelModal({
payload: {
currentProvider,
currentProvider: detail,
currentConfigurationMethod: ConfigurationMethodEnum.predefinedModel,
},
onSaveCallback: () => {
@@ -75,7 +85,7 @@ function PopupItem({
})
}
const state = useCredentialPanelState(currentProvider)
const state = useCredentialPanelInfo(currentProvider)
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider)
const groupItems = useMemo(
() =>
@@ -97,6 +107,14 @@ function PopupItem({
setDropdownOpen(false)
onHide()
}, [onHide])
const handleDropdownOpenChange = async (nextOpen: boolean) => {
if (!nextOpen) {
setDropdownOpen(false)
return
}
const detail = await loadProviderDetail()
if (detail) setDropdownOpen(true)
}
if (!currentProvider) return null
@@ -105,10 +123,10 @@ function PopupItem({
<div className="sticky top-0 z-1 flex h-5.5 min-w-0 items-center justify-between gap-2 bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
<button
type="button"
className="flex min-w-0 cursor-pointer items-center border-0 bg-transparent p-0 text-left"
className="flex min-w-0 cursor-pointer items-center border-0 bg-transparent p-0 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => setCollapsed((prev) => !prev)}
>
<span className="truncate">{model.label[language] || model.label.en_US}</span>
<span className="truncate">{renderI18nObject(model.label, language)}</span>
<span
className={cn(
'i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 text-text-quaternary',
@@ -116,18 +134,18 @@ function PopupItem({
)}
/>
</button>
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<Popover open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
<PopoverTrigger
disabled={!canOpenCredentialDropdown}
render={
<button
type="button"
className="flex max-w-[50%] min-w-0 shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-components-button-ghost-bg-hover"
className="flex max-w-[50%] min-w-0 shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary outline-hidden hover:bg-components-button-ghost-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{isUsingCredits ? (
hasCredits ? (
<>
<CreditsCoin className="size-3" />
<span className="i-custom-vender-line-financeandecommerce-credits-coin size-3" />
<span className="ml-1 truncate">
{t(($) => $['modelProvider.selector.aiCredits'], { ns: 'common' })}
</span>
@@ -160,13 +178,15 @@ function PopupItem({
}
/>
<PopoverContent placement="bottom-end">
<DropdownContent
provider={currentProvider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={handleChangePriority}
onClose={handleCloseDropdown}
/>
{providerDetail && (
<DropdownContent
provider={providerDetail}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={handleChangePriority}
onClose={handleCloseDropdown}
/>
)}
</PopoverContent>
</Popover>
</div>
@@ -1,6 +1,6 @@
import type { DefaultModel, Model } from '../declarations'
import type { ModelSelectorPreviewPayload } from './popup-item'
import type { ModelSelectorModelPredicate } from './types'
import type { ModelSelectorModelPredicate, ModelSelectorProvider } from './types'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { ComboboxList } from '@langgenius/dify-ui/combobox'
import {
@@ -20,17 +20,14 @@ import {
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { consoleQuery } from '@/service/client'
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import {
CustomConfigurationStatusEnum,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../declarations'
import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
import { CustomConfigurationStatusEnum, ModelFeatureEnum, ModelTypeEnum } from '../declarations'
import { useLanguage } from '../hooks'
import ModelBadge from '../model-badge'
import ModelIcon from '../model-icon'
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
@@ -89,10 +86,13 @@ function Popup({
)
const { theme } = useTheme()
const language = useLanguage()
const [previewCardHandle] = useState(() => createPreviewCardHandle<ModelSelectorPreviewPayload>())
const previewCardHandle = useMemo(
() => createPreviewCardHandle<ModelSelectorPreviewPayload>(),
[],
)
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
const [showIncompatibleModels, setShowIncompatibleModels] = useState(false)
const { modelProviders } = useProviderContext()
const { modelProviders, modelProviderPlugins = {} } = useProviderContext()
const { data: enableMarketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: (systemFeatures) => systemFeatures.enable_marketplace,
@@ -101,11 +101,6 @@ function Popup({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const { plugins: allPlugins, isLoading: isMarketplacePluginsLoading } = useMarketplaceAllPlugins(
modelProviders,
'',
enableMarketplace,
)
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
const { refreshPluginList } = useRefreshPluginList()
const { canInstallPlugin } = useWorkspacePluginInstallPermission()
@@ -141,71 +136,68 @@ function Popup({
const hasApiKeyFallback = modelProviders.some((provider) => {
const isApiKeyActive =
provider.custom_configuration?.status === CustomConfigurationStatusEnum.active
return isApiKeyActive && providerSupportsCredits(provider, trialModels, deploymentEdition)
return (
isApiKeyActive &&
provider.custom_configuration.current_credential_usable &&
providerSupportsCredits(provider, trialModels, deploymentEdition)
)
})
const handleInstallPlugin = useCallback(
async (key: ModelProviderQuotaGetPaid) => {
if (
!enableMarketplace ||
!canInstallPlugin ||
!allPlugins ||
isMarketplacePluginsLoading ||
installingProvider
)
return
if (!enableMarketplace || !canInstallPlugin || installingProvider) return
const pluginId = providerKeyToPluginId[key]
const plugin = allPlugins.find((p) => p.plugin_id === pluginId)
if (!plugin) return
const uniqueIdentifier = plugin.latest_package_identifier
const [org, name] = pluginId.split('/')
if (!org || !name) return
setInstallingProvider(key)
try {
const pluginInfo = await fetchPluginInfoFromMarketPlace({ org, name })
const uniqueIdentifier = pluginInfo.data.plugin.latest_package_identifier
if (!uniqueIdentifier) return
const { all_installed, task_id } = await installPackageFromMarketPlace(uniqueIdentifier)
if (!all_installed) {
const { check } = checkTaskStatus()
await check({ taskId: task_id, pluginUniqueIdentifier: uniqueIdentifier })
}
refreshPluginList(plugin)
refreshPluginList({ category: PluginCategoryEnum.model })
} catch {
} finally {
setInstallingProvider(null)
}
},
[
allPlugins,
enableMarketplace,
canInstallPlugin,
installPackageFromMarketPlace,
installingProvider,
isMarketplacePluginsLoading,
refreshPluginList,
],
)
const installedModelList = useMemo(() => {
const modelMap = new Map(modelList.map((model) => [model.provider, model]))
const installedMarketplaceModels = MODEL_PROVIDER_QUOTA_GET_PAID.flatMap((providerKey) => {
const installedProvider = installedProviderMap.get(providerKey)
const installedMarketplaceModels = MODEL_PROVIDER_QUOTA_GET_PAID.flatMap<ModelSelectorProvider>(
(providerKey) => {
const installedProvider = installedProviderMap.get(providerKey)
if (!installedProvider) return []
if (!installedProvider) return []
const matchedModel = modelMap.get(providerKey)
if (matchedModel) return [matchedModel]
const matchedModel = modelMap.get(providerKey)
if (matchedModel) return [matchedModel]
if (!aiCreditVisibleProviders.has(providerKey)) return []
if (!aiCreditVisibleProviders.has(providerKey)) return []
return [
{
provider: installedProvider.provider,
icon_small: installedProvider.icon_small,
icon_small_dark: installedProvider.icon_small_dark,
label: installedProvider.label,
models: [],
status: ModelStatusEnum.active,
},
]
})
return [
{
provider: installedProvider.provider,
icon_small: installedProvider.icon_small,
icon_small_dark: installedProvider.icon_small_dark,
label: installedProvider.label,
models: [],
},
]
},
)
const otherModels = modelList.filter(
(model) =>
!MODEL_PROVIDER_QUOTA_GET_PAID.includes(model.provider as ModelProviderQuotaGetPaid),
@@ -245,9 +237,13 @@ function Popup({
const marketplaceProviders = useMemo(() => {
if (!enableMarketplace) return []
const installedProviders = new Set(modelProviders.map((provider) => provider.provider))
return MODEL_PROVIDER_QUOTA_GET_PAID.filter((key) => !installedProviders.has(key))
}, [enableMarketplace, modelProviders])
const installedPluginIds = new Set(
Object.values(modelProviderPlugins).map((plugin) => plugin.plugin_id),
)
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(
(key) => !installedPluginIds.has(providerKeyToPluginId[key]),
)
}, [enableMarketplace, modelProviderPlugins])
const handleOpenSettings = useCallback(() => {
onHide()
@@ -307,7 +303,6 @@ function Popup({
marketplaceProviders={marketplaceProviders}
marketplaceCollapsed={marketplaceCollapsed}
installingProvider={installingProvider}
isMarketplacePluginsLoading={isMarketplacePluginsLoading}
canInstallPlugin={canInstallPlugin}
theme={theme}
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
@@ -322,7 +317,7 @@ function Popup({
<ModelSelectorPreviewCard
capabilitiesLabel={t(($) => $['model.capabilities'], { ns: 'common' })}
language={language}
payload={payload}
payload={payload as ModelSelectorPreviewPayload | undefined}
/>
)}
</PreviewCard>
@@ -1,11 +1,23 @@
import type { Model, ModelItem } from '../declarations'
import type { I18nObject } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelItem } from '../declarations'
export type ModelSelectorValue = {
provider: string
model: string
}
export type ModelSelectorModelPredicate = (provider: Model, modelItem: ModelItem) => boolean
export type ModelSelectorProvider = {
provider: string
icon_small?: I18nObject | null
icon_small_dark?: I18nObject | null
label: I18nObject
models: ModelItem[]
}
export type ModelSelectorModelPredicate = (
provider: ModelSelectorProvider,
modelItem: ModelItem,
) => boolean
export const isSameModelSelectorValue = (
itemValue: ModelSelectorValue,
@@ -1,6 +1,6 @@
import type { ReactElement } from 'react'
import type { ModelProvider } from '../../declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { ModelProviderPluginSummary } from '../../index'
import { QueryClient } from '@tanstack/react-query'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
@@ -229,15 +229,11 @@ describe('ProviderAddedCard', () => {
renderWithQueryClient(
<ProviderAddedCard
provider={mockProvider}
pluginDetail={
pluginSummary={
{
plugin_id: 'provider-plugin',
plugin_unique_identifier: 'provider-plugin@1.0.0',
declaration: {
author: 'langgenius',
description: { en_US: 'Provider plugin' },
},
} as PluginDetail
installation_id: 'provider-plugin@1.0.0',
} as ModelProviderPluginSummary
}
/>,
)
@@ -352,29 +348,44 @@ describe('ProviderAddedCard', () => {
const customConfigProvider = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.customizableModel],
custom_configuration: { has_custom_models: true },
} as unknown as ModelProvider
const { unmount } = renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
expect(screen.getByTestId('manage-custom-model')).toBeInTheDocument()
expect(screen.getByTestId('add-custom-model')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.modelProvider.auth.manageCredentials' }),
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.modelProvider.addModel' }),
).toBeInTheDocument()
unmount()
mockIsCurrentWorkspaceManager = false
mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'common.modelProvider.auth.manageCredentials' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'common.modelProvider.addModel' }),
).not.toBeInTheDocument()
})
it('should render custom model actions when user can configure models without credential permissions', () => {
const customConfigProvider = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.customizableModel],
custom_configuration: { has_custom_models: false },
} as unknown as ModelProvider
mockWorkspacePermissionKeys = ['plugin.model_config']
renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
expect(screen.getByTestId('manage-custom-model')).toBeInTheDocument()
expect(screen.getByTestId('add-custom-model')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'common.modelProvider.auth.manageCredentials' }),
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.modelProvider.addModel' }),
).toBeInTheDocument()
})
})
@@ -0,0 +1,90 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import LazyCustomModelActions from '../lazy-custom-model-actions'
const { mockLoadProviderDetail, mockOpenModelModal, providerDetail } = vi.hoisted(() => {
const providerDetail = {
provider: 'langgenius/openai/openai',
custom_configuration: {
custom_models: [{ model: 'custom-gpt' }],
can_added_models: [{ model: 'custom-gpt-2', model_type: 'llm' }],
},
}
return {
mockLoadProviderDetail: vi.fn().mockResolvedValue(providerDetail),
mockOpenModelModal: vi.fn(),
providerDetail,
}
})
vi.mock('../../hooks', async () => {
const { useState } = await import('react')
return {
useLazyModelProviderDetail: () => {
const [detail, setDetail] = useState<typeof providerDetail>()
return {
providerDetail: detail,
loadProviderDetail: async () => {
const loadedDetail = await mockLoadProviderDetail()
setDetail(loadedDetail)
return loadedDetail
},
isLoadingProviderDetail: false,
}
},
useModelModalHandler: () => mockOpenModelModal,
}
})
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
AddCustomModel: ({ open }: { open?: boolean }) => (
<div data-open={open} data-testid="add-custom-model" />
),
ManageCustomModelCredentials: ({ isOpen }: { isOpen?: boolean }) => (
<div data-open={isOpen} data-testid="manage-custom-model" />
),
}))
const createProvider = (hasCustomModels: boolean) =>
({
provider: 'langgenius/openai/openai',
custom_configuration: {
has_custom_models: hasCustomModels,
},
}) as ModelProviderSummaryResponse
describe('LazyCustomModelActions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads provider detail and opens credential management on demand', async () => {
const user = userEvent.setup()
render(<LazyCustomModelActions provider={createProvider(true)} />)
expect(mockLoadProviderDetail).not.toHaveBeenCalled()
await user.click(
screen.getByRole('button', { name: 'common.modelProvider.auth.manageCredentials' }),
)
expect(await screen.findByTestId('manage-custom-model')).toHaveAttribute('data-open', 'true')
expect(mockLoadProviderDetail).toHaveBeenCalledTimes(1)
})
it('does not render credential management without custom models', () => {
render(<LazyCustomModelActions provider={createProvider(false)} />)
expect(
screen.queryByRole('button', { name: 'common.modelProvider.auth.manageCredentials' }),
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.modelProvider.addModel' }),
).toBeInTheDocument()
})
})
@@ -0,0 +1,75 @@
import type { ModelItem, ModelProvider } from '../../declarations'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import ModelList from '../model-list'
const { mockToastError } = vi.hoisted(() => ({
mockToastError: vi.fn(),
}))
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({
workspacePermissionKeys: ['plugin.model_config'],
}))
})
vi.mock('../../hooks', () => ({
useLazyModelProviderDetail: () => ({
loadProviderDetail: vi.fn(),
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
vi.mock('../model-load-balancing-modal', () => {
throw new Error('Failed to load model load balancing modal')
})
vi.mock('../model-list-item', () => ({
default: ({
model,
onModifyLoadBalancing,
}: {
model: ModelItem
onModifyLoadBalancing: (model: ModelItem) => void
}) => (
<button type="button" onClick={() => onModifyLoadBalancing(model)}>
{model.model}
</button>
),
}))
describe('ModelList dynamic import failure', () => {
const provider = {
provider: 'test-provider',
configurate_methods: [],
} as unknown as ModelProvider
const model = {
model: 'gpt-4',
model_type: 'llm',
fetch_from: 'system',
} as unknown as ModelItem
beforeEach(() => {
vi.clearAllMocks()
})
it('clears the loading dialog and reports an error when the modal module cannot load', async () => {
render(<ModelList provider={provider} models={[model]} onCollapse={vi.fn()} />)
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('common.api.actionFailed')
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
@@ -1,12 +1,18 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelItem, ModelProvider } from '../../declarations'
import type { ModelLoadBalancingModalProps } from '../model-load-balancing-modal'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { render } from '@/test/console/render'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { ConfigurationMethodEnum } from '../../declarations'
import ModelList from '../model-list'
const mockLoadProviderDetail = vi.fn()
const { mockLoadModelLoadBalancingModal } = vi.hoisted(() => ({
mockLoadModelLoadBalancingModal: vi.fn(),
}))
const { mockToastError } = vi.hoisted(() => ({
mockToastError: vi.fn(),
}))
let mockWorkspacePermissionKeys: string[] = [
'plugin.model_config',
'credential.manage',
@@ -20,51 +26,65 @@ vi.mock('@/context/permission-state', async () => {
}))
})
vi.mock('@/next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
const LazyComponent = React.lazy(loader)
return function DynamicComponent(props: Record<string, unknown>) {
return React.createElement(
React.Suspense,
{ fallback: null },
React.createElement(LazyComponent, props),
)
}
vi.mock('../../hooks', () => ({
useLazyModelProviderDetail: () => ({
loadProviderDetail: mockLoadProviderDetail,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
vi.mock('../model-load-balancing-modal', () => ({
default: ({ model, onClose, onSave, open, provider }: ModelLoadBalancingModalProps) =>
open ? (
<div role="dialog" aria-label={`Load balancing for ${model.model}`}>
<span>{provider.provider}</span>
<button type="button" onClick={onClose}>
Close
</button>
<button
type="button"
onClick={() => {
onSave?.(provider.provider)
onClose?.()
}}
>
Save
</button>
</div>
) : null,
const MockModelLoadBalancingModal = ({
onClose,
onSave,
}: {
onClose?: () => void
onSave?: (provider: string) => void
}) => (
<div data-testid="model-load-balancing-modal">
<button type="button" onClick={() => onSave?.('test-provider')}>
Save load balancing
</button>
<button type="button" onClick={onClose}>
Close load balancing
</button>
</div>
)
vi.mock('../model-load-balancing-modal', () => mockLoadModelLoadBalancingModal())
vi.mock('../lazy-custom-model-actions', () => ({
default: ({ provider }: { provider: ModelProvider }) => (
<>
{(provider.custom_configuration.custom_models?.length ?? 0) > 0 && (
<div data-testid="manage-credentials" />
)}
<button type="button" data-testid="add-custom-model">
common.modelProvider.addModel
</button>
</>
),
}))
vi.mock('../model-list-item', () => ({
default: ({
model,
isLoadingLoadBalancing,
isLoadBalancingDisabled,
onModifyLoadBalancing,
}: {
model: ModelItem
isLoadingLoadBalancing?: boolean
isLoadBalancingDisabled?: boolean
onModifyLoadBalancing: (model: ModelItem) => void
}) => (
<button
type="button"
aria-label={`Modify load balancing for ${model.model}`}
disabled={isLoadingLoadBalancing || isLoadBalancingDisabled}
onClick={() => onModifyLoadBalancing(model)}
>
{model.model}
@@ -72,15 +92,11 @@ vi.mock('../model-list-item', () => ({
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
ManageCustomModelCredentials: () => <div data-testid="manage-credentials" />,
AddCustomModel: () => <div data-testid="add-custom-model" />,
}))
describe('ModelList', () => {
const mockProvider = {
provider: 'test-provider',
configurate_methods: ['customizableModel'],
custom_configuration: { custom_models: [] },
} as unknown as ModelProvider
const mockModels = [
@@ -90,12 +106,69 @@ describe('ModelList', () => {
const mockOnCollapse = vi.fn()
const mockOnChange = vi.fn()
const createSummaryProvider = (): ModelProviderSummaryResponse => ({
provider: 'test-provider',
plugin_id: 'test-plugin',
label: { en_US: 'Test provider', zh_Hans: 'Test provider' },
supported_model_types: ['llm'],
configurate_methods: ['customizable-model'],
preferred_provider_type: 'system',
is_configured: true,
custom_configuration: {
status: 'active',
has_custom_models: false,
available_credentials: [],
current_credential_usable: false,
},
system_configuration: { enabled: false },
})
beforeEach(() => {
vi.clearAllMocks()
mockLoadProviderDetail.mockResolvedValue(mockProvider)
mockLoadModelLoadBalancingModal.mockResolvedValue({
default: MockModelLoadBalancingModal,
})
mockWorkspacePermissionKeys = ['plugin.model_config', 'credential.manage', 'credential.use']
})
it('should allow reopening the loading dialog after it is dismissed before the module is available', async () => {
let resolveModule:
| ((module: { default: typeof MockModelLoadBalancingModal }) => void)
| undefined
mockLoadModelLoadBalancingModal.mockImplementationOnce(
() =>
new Promise<{ default: typeof MockModelLoadBalancingModal }>((resolve) => {
resolveModule = resolve
}),
)
render(
<ModelList
provider={mockProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
expect(await screen.findByRole('status')).toHaveAttribute('aria-busy', 'true')
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
expect(await screen.findByRole('status')).toHaveAttribute('aria-busy', 'true')
resolveModule?.({ default: MockModelLoadBalancingModal })
expect(await screen.findByTestId('model-load-balancing-modal')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('should render model count and model items', () => {
render(
<ModelList
@@ -106,12 +179,8 @@ describe('ModelList', () => {
/>,
)
expect(screen.getAllByText(/modelProvider\.modelsNum/).length).toBeGreaterThan(0)
expect(
screen.getByRole('button', { name: 'Modify load balancing for gpt-4' }),
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Modify load balancing for gpt-3.5' }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'gpt-4' }))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'gpt-3.5' }))!.toBeInTheDocument()
})
it('should trigger collapse when collapsed label is clicked', () => {
@@ -129,8 +198,7 @@ describe('ModelList', () => {
expect(mockOnCollapse).toHaveBeenCalled()
})
it('should open the selected model and reset the payload after close', async () => {
const user = userEvent.setup()
it('should open load balancing modal for selected model', async () => {
render(
<ModelList
provider={mockProvider}
@@ -140,28 +208,78 @@ describe('ModelList', () => {
/>,
)
await user.click(screen.getByRole('button', { name: 'Modify load balancing for gpt-4' }))
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
expect(mockLoadProviderDetail).not.toHaveBeenCalled()
expect(await screen.findByTestId('model-load-balancing-modal')).toBeInTheDocument()
})
const firstDialog = await screen.findByRole('dialog', {
name: 'Load balancing for gpt-4',
})
expect(within(firstDialog).getByText('test-provider')).toBeInTheDocument()
it('should load provider detail before opening load balancing from a summary card', async () => {
const summaryProvider = createSummaryProvider()
await user.click(within(firstDialog).getByRole('button', { name: 'Close' }))
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: 'Load balancing for gpt-4' }),
).not.toBeInTheDocument()
})
render(
<ModelList
provider={summaryProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
await user.click(screen.getByRole('button', { name: 'Modify load balancing for gpt-3.5' }))
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
expect(
await screen.findByRole('dialog', { name: 'Load balancing for gpt-3.5' }),
).toBeInTheDocument()
expect(
screen.queryByRole('dialog', { name: 'Load balancing for gpt-4' }),
).not.toBeInTheDocument()
expect(mockLoadProviderDetail).toHaveBeenCalledOnce()
expect(await screen.findByTestId('model-load-balancing-modal')).toBeInTheDocument()
})
it('should disable load balancing while provider detail is loading', async () => {
let resolveProviderDetail: (provider: ModelProvider) => void = () => {}
mockLoadProviderDetail.mockReturnValue(
new Promise<ModelProvider>((resolve) => {
resolveProviderDetail = resolve
}),
)
const summaryProvider = createSummaryProvider()
render(
<ModelList
provider={summaryProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
const user = userEvent.setup()
const modelButton = screen.getByRole('button', { name: 'gpt-4' })
await user.click(modelButton)
expect(modelButton).toBeDisabled()
expect(screen.getByRole('button', { name: 'gpt-3.5' })).toBeDisabled()
resolveProviderDetail(mockProvider)
expect(await screen.findByTestId('model-load-balancing-modal')).toBeInTheDocument()
})
it('should show an error when provider detail cannot be loaded', async () => {
mockLoadProviderDetail.mockResolvedValue(undefined)
const summaryProvider = createSummaryProvider()
render(
<ModelList
provider={summaryProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
expect(mockToastError).toHaveBeenCalledWith('common.api.actionFailed')
expect(screen.queryByTestId('model-load-balancing-modal')).not.toBeInTheDocument()
})
it('should hide custom model actions without plugin.model_config', () => {
@@ -230,8 +348,7 @@ describe('ModelList', () => {
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
})
it('should refresh the provider and close after saving load balancing changes', async () => {
const user = userEvent.setup()
it('should call onSave (onChange) and close the load balancing modal', async () => {
render(
<ModelList
provider={mockProvider}
@@ -241,19 +358,13 @@ describe('ModelList', () => {
/>,
)
await user.click(screen.getByRole('button', { name: 'Modify load balancing for gpt-4' }))
const dialog = await screen.findByRole('dialog', {
name: 'Load balancing for gpt-4',
})
await user.click(within(dialog).getByRole('button', { name: 'Save' }))
fireEvent.click(screen.getByRole('button', { name: 'gpt-4' }))
await screen.findByTestId('model-load-balancing-modal')
fireEvent.click(screen.getByRole('button', { name: 'Save load balancing' }))
expect(mockOnChange).toHaveBeenCalledWith('test-provider')
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: 'Load balancing for gpt-4' }),
).not.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'Close load balancing' }))
expect(screen.queryByTestId('model-load-balancing-modal')).not.toBeInTheDocument()
})
it('should hide custom model actions when provider uses fetchFromRemote only', () => {
@@ -307,10 +418,11 @@ describe('ModelList', () => {
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
})
it('should show custom model actions when provider is configurable and user can configure models', () => {
it('should show Add Model but hide Manage Credentials for configurable providers', () => {
const configurableProvider = {
provider: 'test-provider',
configurate_methods: [ConfigurationMethodEnum.customizableModel],
custom_configuration: { custom_models: [] },
} as unknown as ModelProvider
mockWorkspacePermissionKeys = ['plugin.model_config']
@@ -324,14 +436,17 @@ describe('ModelList', () => {
/>,
)
expect(screen.getByTestId('manage-credentials'))!.toBeInTheDocument()
expect(screen.getByTestId('add-custom-model'))!.toBeInTheDocument()
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.modelProvider.addModel' }),
).toBeInTheDocument()
})
it('should hide custom model actions when provider is configurable but user cannot configure models', () => {
const configurableProvider = {
provider: 'test-provider',
configurate_methods: [ConfigurationMethodEnum.customizableModel],
custom_configuration: { custom_models: [] },
} as unknown as ModelProvider
mockWorkspacePermissionKeys = []
@@ -1,7 +1,11 @@
import type { ReactElement } from 'react'
import type { ModelProviderPluginSummary } from '../../index'
import type { PluginDetail } from '@/app/components/plugins/types'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PluginSource } from '@/app/components/plugins/types'
import { consoleQuery } from '@/service/client'
import { commonQueryKeys } from '@/service/use-common'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import ProviderCardActions from '../provider-card-actions'
@@ -13,6 +17,16 @@ const mockShowPluginInfo = vi.fn()
const mockShowDeleteConfirm = vi.fn()
const mockSetTargetVersion = vi.fn()
const mockSetVersionPickerOpen = vi.fn()
const { mockNormalizeInstalledPluginDetail, mockUninstallPlugin, mockUseVersionListOfPlugin } =
vi.hoisted(() => ({
mockNormalizeInstalledPluginDetail: vi.fn(),
mockUninstallPlugin: vi.fn(),
mockUseVersionListOfPlugin: vi.fn(),
}))
const mockPluginSettingsAccess = vi.hoisted(() => ({
canDeletePlugin: true,
canUpdatePlugin: true,
}))
let mockHeaderState = {
modalStates: {
@@ -48,10 +62,6 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/hooks', () =
}),
}))
vi.mock('@/service/use-plugins', () => ({
useVersionListOfPlugin: () => ({ data: { data: { versions: [] } } }),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/components', () => ({
HeaderModals: ({
targetVersion,
@@ -72,10 +82,7 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/components',
}))
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
usePluginSettingsAccess: () => ({
canDeletePlugin: true,
canUpdatePlugin: true,
}),
usePluginSettingsAccess: () => mockPluginSettingsAccess,
default: () => ({
canUpdate: true,
}),
@@ -89,6 +96,15 @@ vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (...args: unknown[]) => mockGetMarketplaceUrl(...args),
}))
vi.mock('@/service/plugins', () => ({
uninstallPlugin: mockUninstallPlugin,
}))
vi.mock('@/service/use-plugins', () => ({
normalizeInstalledPluginDetail: mockNormalizeInstalledPluginDetail,
useVersionListOfPlugin: mockUseVersionListOfPlugin,
}))
const createDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail =>
({
plugin_id: 'plugin-id',
@@ -106,6 +122,18 @@ const createDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail =>
...overrides,
}) as PluginDetail
const createSummary = (
overrides: Partial<ModelProviderPluginSummary> = {},
): ModelProviderPluginSummary => ({
installation_id: 'installation-id',
plugin_id: 'langgenius/provider-plugin',
plugin_unique_identifier: 'langgenius/provider-plugin@1.0.0',
runtime_type: 'local',
source: 'github',
version: '1.0.0',
...overrides,
})
describe('ProviderCardActions', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -129,12 +157,170 @@ describe('ProviderCardActions', () => {
mockGetMarketplaceUrl.mockReturnValue(
'https://marketplace.example.com/plugins/langgenius/provider-plugin',
)
mockNormalizeInstalledPluginDetail.mockReturnValue(
createDetail({
source: PluginSource.github,
meta: {
repo: 'langgenius/provider-plugin',
version: '1.0.0',
package: 'provider-plugin.difypkg',
},
}),
)
mockUninstallPlugin.mockResolvedValue({ success: true })
mockUseVersionListOfPlugin.mockReturnValue({
data: {
data: {
versions: [
{
version: '0.9.0',
unique_identifier: 'plugin@0.9.0',
created_at: 0,
},
],
},
},
isLoading: false,
})
mockPluginSettingsAccess.canDeletePlugin = true
mockPluginSettingsAccess.canUpdatePlugin = true
})
it('should render version controls for marketplace plugins', () => {
it('should load plugin detail and continue the requested info action', async () => {
mockHeaderState = {
...mockHeaderState,
hasNewVersion: false,
isFromMarketplace: false,
isFromGitHub: true,
}
const rendered = render(
<ProviderCardActions summary={createSummary()} providerLabel="Provider Plugin" />,
)
const fetchQuery = vi
.spyOn(rendered.queryClient, 'fetchQuery')
.mockResolvedValue({ plugins: [{}] })
openActionsMenu()
fireEvent.click(screen.getByText('plugin.detailPanel.operation.info'))
await waitFor(() => {
expect(mockShowPluginInfo).toHaveBeenCalledTimes(1)
})
expect(fetchQuery).toHaveBeenCalledTimes(1)
expect(mockNormalizeInstalledPluginDetail).toHaveBeenCalledTimes(1)
})
it('should remove from summary without loading plugin detail', async () => {
const rendered = render(
<ProviderCardActions summary={createSummary()} providerLabel="Provider Plugin" />,
)
const fetchQuery = vi.spyOn(rendered.queryClient, 'fetchQuery')
const invalidateQueries = vi.spyOn(rendered.queryClient, 'invalidateQueries')
openActionsMenu()
fireEvent.click(screen.getByText('plugin.detailPanel.operation.remove'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockUninstallPlugin).toHaveBeenCalledWith('installation-id')
})
expect(fetchQuery).not.toHaveBeenCalled()
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.workspaces.current.plugin.installedIds.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.workspaces.current.plugin.list.installations.ids.post.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.workspaces.current.plugin.list.latestVersions.post.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: commonQueryKeys.modelProviderDetails,
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['marketplacePlugins'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['marketplaceCollectionPlugins'] })
})
it('should only render an interactive summary version when marketplace updates are allowed', () => {
const { rerender } = render(
<ProviderCardActions summary={createSummary()} providerLabel="Provider Plugin" />,
)
expect(screen.queryByRole('button', { name: '1.0.0' })).not.toBeInTheDocument()
expect(screen.getByText('1.0.0')).toBeInTheDocument()
rerender(
<ProviderCardActions
summary={createSummary({ source: 'marketplace' })}
providerLabel="Provider Plugin"
/>,
)
expect(screen.getByRole('button', { name: '1.0.0' })).toBeInTheDocument()
mockPluginSettingsAccess.canUpdatePlugin = false
rerender(
<ProviderCardActions
summary={createSummary({ source: 'marketplace' })}
providerLabel="Provider Plugin"
/>,
)
expect(screen.queryByRole('button', { name: '1.0.0' })).not.toBeInTheDocument()
expect(screen.getByText('1.0.0')).toBeInTheDocument()
})
it('should use the summary latest version when updating a marketplace plugin', async () => {
mockNormalizeInstalledPluginDetail.mockReturnValue(
createDetail({
latest_version: '1.0.0',
latest_unique_identifier: 'plugin-id@1.0.0',
}),
)
const rendered = render(
<ProviderCardActions
summary={createSummary({
source: 'marketplace',
latestVersion: '2.0.0',
latestUniqueIdentifier: 'plugin-id@2.0.0',
})}
providerLabel="Provider Plugin"
/>,
)
vi.spyOn(rendered.queryClient, 'fetchQuery').mockResolvedValue({ plugins: [{}] })
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.update' }))
await waitFor(() => {
expect(mockSetTargetVersion).toHaveBeenCalledWith({
version: '2.0.0',
unique_identifier: 'plugin-id@2.0.0',
})
})
})
it('should render version controls for marketplace plugins and handle manual version selection', async () => {
const user = userEvent.setup()
mockHeaderState = {
...mockHeaderState,
versionPicker: {
...mockHeaderState.versionPicker,
isShow: true,
},
}
render(<ProviderCardActions detail={createDetail()} />)
expect(screen.getByRole('button', { name: '1.0.0' })).toBeEnabled()
expect(screen.getByRole('button', { name: '1.0.0' })).not.toBeDisabled()
await user.click(screen.getByRole('button', { name: /^0\.9\.0/ }))
expect(mockSetTargetVersion).toHaveBeenCalledWith({
version: '0.9.0',
unique_identifier: 'plugin@0.9.0',
isDowngrade: true,
})
expect(mockHandleUpdate).toHaveBeenCalledWith(true)
})
it('should show a compact debug badge after the version for debugging plugins', () => {
@@ -160,6 +346,15 @@ describe('ProviderCardActions', () => {
expect(mockHandleUpdate).toHaveBeenCalledWith()
})
it('should not update a marketplace plugin without a latest package identifier', () => {
render(<ProviderCardActions detail={createDetail({ latest_unique_identifier: '' })} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.update' }))
expect(mockSetTargetVersion).not.toHaveBeenCalled()
expect(mockHandleUpdate).not.toHaveBeenCalled()
})
it('should pass the marketplace detail url to the operation dropdown', () => {
render(<ProviderCardActions detail={createDetail()} />)
@@ -8,6 +8,7 @@ let mockWorkspaceData:
| {
trial_credits: number
trial_credits_used: number
is_unlimited?: boolean
trial_credits_exhausted_at?: number
next_credit_reset_date: number
}
@@ -24,6 +25,42 @@ let mockPlugins = [
latest_package_identifier: 'openai@1.0.0',
},
]
const mockFetchManifestFromMarketPlace = vi.fn(async (_uniqueIdentifier: string) => ({
data: {
plugin: {
name: 'registry-openai',
org: 'marketplace-cache',
icon: '',
label: { en_US: 'OpenAI' },
category: 'model' as const,
version: '1.0.0',
latest_version: '1.0.0',
brief: {},
introduction: '',
verified: true,
install_count: 0,
badges: [],
verification: { authorized_category: 'langgenius' as const },
from: 'package' as const,
},
},
}))
const mockFetchPluginInfoFromMarketPlace = vi.fn(async (_params: Record<string, string>) => ({
data: {
plugin: {
category: 'model' as const,
latest_package_identifier: 'openai@1.0.0',
latest_version: '1.0.0',
},
},
}))
vi.mock('@/service/plugins', () => ({
fetchManifestFromMarketPlace: (uniqueIdentifier: string) =>
mockFetchManifestFromMarketPlace(uniqueIdentifier),
fetchPluginInfoFromMarketPlace: (params: Record<string, string>) =>
mockFetchPluginInfoFromMarketPlace(params),
}))
vi.mock('@/app/components/base/icons/src/public/llm', () => {
const Icon = ({ label }: { label: string }) => <span>{label}</span>
@@ -39,16 +76,20 @@ vi.mock('@/app/components/base/icons/src/public/llm', () => {
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => {
const totalCredits = Math.max(mockWorkspaceData?.trial_credits ?? 0, 0)
const isUnlimited = mockWorkspaceData?.is_unlimited ?? false
const rawTotalCredits = mockWorkspaceData?.trial_credits ?? 0
const rawUsedCredits = mockWorkspaceData?.trial_credits_used ?? 0
const normalizedUsedCredits = Math.max(rawUsedCredits, 0)
const usedCredits = Math.min(normalizedUsedCredits, totalCredits)
const credits = Math.max(totalCredits - usedCredits, 0)
const totalCredits = isUnlimited ? rawTotalCredits : Math.max(rawTotalCredits, 0)
const usedCredits = isUnlimited
? rawUsedCredits
: Math.min(Math.max(rawUsedCredits, 0), totalCredits)
const credits = isUnlimited ? -1 : Math.max(totalCredits - usedCredits, 0)
return {
credits,
usedCredits,
totalCredits,
isExhausted: credits <= 0,
isUnlimited,
isExhausted: !isUnlimited && credits <= 0,
isLoading: mockWorkspaceIsPending && !mockWorkspaceData,
exhaustedAt: mockWorkspaceData?.trial_credits_exhausted_at,
nextCreditResetDate: mockWorkspaceData?.next_credit_reset_date,
@@ -86,8 +127,23 @@ vi.mock('@/hooks/use-timestamp', () => ({
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div>
default: ({
manifest,
uniqueIdentifier,
onClose,
}: {
manifest: { from: string; icon: string; name: string; org: string }
uniqueIdentifier: string
onClose: () => void
}) => (
<div
data-icon={manifest.icon}
data-from={manifest.from}
data-name={manifest.name}
data-org={manifest.org}
data-unique-identifier={uniqueIdentifier}
data-testid="install-modal"
>
<span>install modal</span>
<button type="button" onClick={onClose}>
close install
@@ -115,6 +171,17 @@ describe('QuotaPanel', () => {
mockWorkspaceIsPending = false
mockTrialModels = ['langgenius/openai/openai']
mockPlugins = [{ plugin_id: 'langgenius/openai', latest_package_identifier: 'openai@1.0.0' }]
mockFetchManifestFromMarketPlace.mockClear()
mockFetchPluginInfoFromMarketPlace.mockReset()
mockFetchPluginInfoFromMarketPlace.mockResolvedValue({
data: {
plugin: {
category: 'model',
latest_package_identifier: 'openai@1.0.0',
latest_version: '1.0.0',
},
},
})
})
it('should render loading state', () => {
@@ -167,19 +234,79 @@ describe('QuotaPanel', () => {
expect(screen.queryByText(/modelProvider\.resetDate/)).not.toBeInTheDocument()
})
it('should open install modal when clicking an unsupported trial provider', () => {
it('should show unlimited credits instead of backend sentinel values', () => {
mockWorkspaceData = {
trial_credits: -1,
trial_credits_used: 999,
is_unlimited: true,
next_credit_reset_date: 0,
}
renderQuotaPanel(<QuotaPanel providers={mockProviders} />)
expect(screen.getByText('common.license.unlimited')).toBeInTheDocument()
expect(screen.queryByText('999')).not.toBeInTheDocument()
expect(screen.queryByText('-1')).not.toBeInTheDocument()
expect(screen.queryByText(/modelProvider\.used/)).not.toBeInTheDocument()
})
it('should open install modal when clicking an unsupported trial provider', async () => {
renderQuotaPanel(<QuotaPanel providers={[]} />)
fireEvent.click(screen.getByText('openai'))
expect(screen.getByText('install modal')).toBeInTheDocument()
await waitFor(() => expect(screen.getByText('install modal')).toBeInTheDocument())
expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith('openai@1.0.0')
expect(mockFetchPluginInfoFromMarketPlace).toHaveBeenCalledWith({
org: 'langgenius',
name: 'openai',
})
expect(screen.getByTestId('install-modal')).toHaveAttribute(
'data-unique-identifier',
'openai@1.0.0',
)
expect(screen.getByTestId('install-modal')).toHaveAttribute('data-icon', 'marketplace')
expect(screen.getByTestId('install-modal')).toHaveAttribute('data-from', 'marketplace')
expect(screen.getByTestId('install-modal')).toHaveAttribute('data-org', 'langgenius')
expect(screen.getByTestId('install-modal')).toHaveAttribute('data-name', 'openai')
})
it('should prevent duplicate marketplace requests while a provider is loading', async () => {
mockFetchPluginInfoFromMarketPlace.mockImplementation(() => new Promise(() => {}))
renderQuotaPanel(<QuotaPanel providers={[]} />)
const providerButton = screen.getByLabelText(/modelNotSupported/)
fireEvent.click(providerButton)
fireEvent.click(providerButton)
await waitFor(() => {
expect(mockFetchPluginInfoFromMarketPlace).toHaveBeenCalledTimes(1)
})
expect(providerButton).toHaveAttribute('aria-busy', 'true')
expect(providerButton).toHaveAttribute('aria-disabled', 'true')
})
it('should allow retrying marketplace installation after a request failure', async () => {
mockFetchPluginInfoFromMarketPlace.mockRejectedValueOnce(new Error('Marketplace unavailable'))
renderQuotaPanel(<QuotaPanel providers={[]} />)
const providerButton = screen.getByLabelText(/modelNotSupported/)
fireEvent.click(providerButton)
await waitFor(() => expect(providerButton).toHaveAttribute('aria-busy', 'false'))
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
fireEvent.click(providerButton)
await waitFor(() => expect(screen.getByText('install modal')).toBeInTheDocument())
expect(mockFetchPluginInfoFromMarketPlace).toHaveBeenCalledTimes(2)
})
it('should close install modal when provider becomes installed', async () => {
const { rerender } = renderQuotaPanel(<QuotaPanel providers={[]} />)
fireEvent.click(screen.getByText('openai'))
expect(screen.getByText('install modal')).toBeInTheDocument()
await waitFor(() => expect(screen.getByText('install modal')).toBeInTheDocument())
rerender(<QuotaPanel providers={mockProviders} />)
@@ -13,11 +13,15 @@ vi.mock('@/service/client', () => ({
consoleQuery: {
workspaces: {
current: {
post: {
queryOptions: (options?: object) => ({
queryKey: ['console', 'workspaces', 'current', 'post'],
...options,
}),
modelProviders: {
credits: {
get: {
queryOptions: (options?: object) => ({
queryKey: ['console', 'workspaces', 'current', 'model-providers', 'credits', 'get'],
...options,
}),
},
},
},
},
},
@@ -28,10 +32,13 @@ describe('useTrialCredits', () => {
const mockTrialCreditsQuery = (
data:
| {
trial_credits?: number
trial_credits_used?: number
trial_credits_exhausted_at?: number
next_credit_reset_date?: number
quota_limit?: number | null
quota_used?: number | null
remaining_credits?: number | null
is_unlimited?: boolean
is_exhausted?: boolean
exhausted_at?: number | null
next_credit_reset_date?: number | null
}
| undefined,
isPending = false,
@@ -45,9 +52,11 @@ describe('useTrialCredits', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTrialCreditsQuery({
trial_credits: 100,
trial_credits_used: 40,
trial_credits_exhausted_at: undefined,
quota_limit: 100,
quota_used: 40,
remaining_credits: 60,
is_exhausted: false,
exhausted_at: undefined,
next_credit_reset_date: 1775001600,
})
})
@@ -60,6 +69,7 @@ describe('useTrialCredits', () => {
credits: 60,
usedCredits: 40,
totalCredits: 100,
isUnlimited: false,
isExhausted: false,
isLoading: false,
exhaustedAt: undefined,
@@ -70,8 +80,10 @@ describe('useTrialCredits', () => {
it('should keep the hook out of loading state during a background refetch', () => {
mockTrialCreditsQuery(
{
trial_credits: 80,
trial_credits_used: 20,
quota_limit: 80,
quota_used: 20,
remaining_credits: 60,
is_exhausted: false,
next_credit_reset_date: 1777593600,
},
true,
@@ -82,6 +94,7 @@ describe('useTrialCredits', () => {
expect(result.current.isLoading).toBe(false)
expect(result.current.credits).toBe(60)
expect(result.current.usedCredits).toBe(20)
expect(result.current.isUnlimited).toBe(false)
expect(result.current.isExhausted).toBe(false)
})
})
@@ -96,6 +109,7 @@ describe('useTrialCredits', () => {
credits: 0,
usedCredits: 0,
totalCredits: 0,
isUnlimited: false,
isExhausted: true,
isLoading: true,
exhaustedAt: undefined,
@@ -103,11 +117,13 @@ describe('useTrialCredits', () => {
})
})
it('should clamp negative remaining credits to zero', () => {
it('should use the backend exhausted state', () => {
mockTrialCreditsQuery({
trial_credits: 10,
trial_credits_used: 99,
trial_credits_exhausted_at: 1772323200,
quota_limit: 10,
quota_used: 10,
remaining_credits: 0,
is_exhausted: true,
exhausted_at: 1772323200,
next_credit_reset_date: undefined,
})
@@ -118,5 +134,27 @@ describe('useTrialCredits', () => {
expect(result.current.isExhausted).toBe(true)
expect(result.current.exhaustedAt).toBe(1772323200)
})
it('should preserve the unlimited state without interpreting sentinel credits as usage', () => {
mockTrialCreditsQuery({
quota_limit: -1,
quota_used: 999,
remaining_credits: -1,
is_unlimited: true,
is_exhausted: false,
exhausted_at: null,
next_credit_reset_date: null,
})
const { result } = renderHook(() => useTrialCredits())
expect(result.current).toMatchObject({
credits: -1,
usedCredits: 999,
totalCredits: -1,
isUnlimited: true,
isExhausted: false,
})
})
})
})
@@ -1,3 +1,4 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import type { ModelProvider, PreferredProviderTypeEnum } from '../declarations'
import type { CardVariant } from './use-credential-panel-state'
@@ -11,16 +12,16 @@ import { useChangeProviderPriority } from './use-change-provider-priority'
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
type CredentialPanelProps = {
provider: ModelProvider
provider: ModelProviderSummaryResponse | ModelProvider
}
type CredentialPanelContentProps = {
provider: ModelProvider
provider: ModelProviderSummaryResponse | ModelProvider
state: ReturnType<typeof useCredentialPanelState>
isChangingPriority: boolean
onChangePriority: (key: PreferredProviderTypeEnum) => void
renderActions?: (props: {
provider: ModelProvider
provider: ModelProviderSummaryResponse | ModelProvider
state: ReturnType<typeof useCredentialPanelState>
isChangingPriority: boolean
onChangePriority: (key: PreferredProviderTypeEnum) => void
@@ -1,16 +1,13 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { FC } from 'react'
import type { ModelProvider } from '../declarations'
import type { ModelProviderPluginSummary } from '../index'
import type { ModelProviderQuotaGetPaid } from '../utils'
import type { PluginDetail } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
AddCustomModel,
ManageCustomModelCredentials,
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContextSelector } from '@/context/provider-context'
@@ -31,20 +28,22 @@ import {
normalizeModelProviderModelsResponse,
} from '../utils'
import CredentialPanel from './credential-panel'
import LazyCustomModelActions from './lazy-custom-model-actions'
import ModelList from './model-list'
import ProviderCardActions from './provider-card-actions'
type ProviderAddedCardProps = {
layout?: 'list' | 'grid'
notConfigured?: boolean
provider: ModelProvider
pluginDetail?: PluginDetail
provider: ModelProviderSummaryResponse | ModelProvider
pluginSummary?: ModelProviderPluginSummary
}
const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
layout = 'list',
notConfigured,
provider,
pluginDetail,
pluginSummary,
}) => {
const { t } = useTranslation()
const { data: deploymentEdition } = useSuspenseQuery({
@@ -57,11 +56,11 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
const currentProviderName = provider.provider
const expanded = useModelProviderListExpanded(currentProviderName)
const setExpanded = useSetModelProviderListExpanded(currentProviderName)
const supportsPredefinedModel = provider.configurate_methods.includes(
ConfigurationMethodEnum.predefinedModel,
const supportsPredefinedModel = provider.configurate_methods.some(
(method) => method === ConfigurationMethodEnum.predefinedModel,
)
const supportsCustomizableModel = provider.configurate_methods.includes(
ConfigurationMethodEnum.customizableModel,
const supportsCustomizableModel = provider.configurate_methods.some(
(method) => method === ConfigurationMethodEnum.customizableModel,
)
const systemConfig = provider.system_configuration
const {
@@ -120,14 +119,8 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
}, [expanded, loading, refetchModelList, setExpanded])
const providerLabel = renderI18nObject(provider.label, language)
const description = renderI18nObject(
provider.description ||
pluginDetail?.declaration.description ||
provider.help?.title ||
provider.label,
language,
)
const organization = pluginDetail?.declaration.author || currentProviderName.split('/')[0]
const description = renderI18nObject(provider.description || provider.label, language)
const organization = currentProviderName.split('/')[0]
if (layout === 'grid') {
return (
@@ -158,8 +151,12 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
>
{providerLabel}
</div>
{pluginDetail && (
<ProviderCardActions detail={pluginDetail} onUpdate={refreshPluginData} />
{pluginSummary && (
<ProviderCardActions
summary={pluginSummary}
providerLabel={providerLabel}
onUpdate={refreshPluginData}
/>
)}
</div>
<div className="mt-0.5 flex h-4 min-w-0 items-center gap-2 system-xs-regular text-text-tertiary">
@@ -182,7 +179,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
{(showModelProvider || !notConfigured) && (
<button
type="button"
className="flex h-8 min-w-0 flex-1 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-xs hover:bg-components-button-secondary-bg-hover"
className="flex h-8 min-w-0 flex-1 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-xs outline-hidden hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
aria-label={t(($) => $['modelProvider.showModels'], { ns: 'common' })}
onClick={handleOpenModelList}
>
@@ -213,15 +210,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
{showCredential && <CredentialPanel provider={provider} />}
{showCustomModelActions && (
<div className="flex shrink-0">
<ManageCustomModelCredentials
provider={provider}
currentCustomConfigurationModelFixedFields={undefined}
/>
<AddCustomModel
provider={provider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
currentCustomConfigurationModelFixedFields={undefined}
/>
<LazyCustomModelActions provider={provider} />
</div>
)}
</div>
@@ -253,8 +242,12 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
<div className="grow px-1 pt-1 pb-0.5">
<div className="mb-2 flex items-center gap-1">
<ProviderIcon provider={provider} />
{pluginDetail && (
<ProviderCardActions detail={pluginDetail} onUpdate={refreshPluginData} />
{pluginSummary && (
<ProviderCardActions
summary={pluginSummary}
providerLabel={providerLabel}
onUpdate={refreshPluginData}
/>
)}
</div>
<div className="flex gap-0.5">
@@ -270,7 +263,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
{(showModelProvider || !notConfigured) && (
<button
type="button"
className="flex h-6 items-center rounded-lg border-none bg-transparent pr-1.5 pl-1 text-left hover:bg-components-button-ghost-bg-hover"
className="flex h-6 items-center rounded-lg border-none bg-transparent pr-1.5 pl-1 text-left outline-hidden hover:bg-components-button-ghost-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
aria-label={t(($) => $['modelProvider.showModels'], { ns: 'common' })}
onClick={handleOpenModelList}
>
@@ -291,15 +284,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
)}
{showCustomModelActions && (
<div className="flex grow justify-end">
<ManageCustomModelCredentials
provider={provider}
currentCustomConfigurationModelFixedFields={undefined}
/>
<AddCustomModel
provider={provider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
currentCustomConfigurationModelFixedFields={undefined}
/>
<LazyCustomModelActions provider={provider} />
</div>
)}
</div>
@@ -0,0 +1,96 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelProvider } from '../declarations'
import { Button } from '@langgenius/dify-ui/button'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
AddCustomModel,
ManageCustomModelCredentials,
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { ConfigurationMethodEnum, ModelModalModeEnum } from '../declarations'
import { useLazyModelProviderDetail, useModelModalHandler } from '../hooks'
type ProviderSummary = ModelProviderSummaryResponse | ModelProvider
export default function LazyCustomModelActions({ provider }: { provider: ProviderSummary }) {
const { t } = useTranslation()
const handleOpenModelModal = useModelModalHandler()
const [isAddOpen, setIsAddOpen] = useState(false)
const [isManageOpen, setIsManageOpen] = useState(false)
const { providerDetail, loadProviderDetail, isLoadingProviderDetail } =
useLazyModelProviderDetail(provider.provider)
const hasCustomModels =
'has_custom_models' in provider.custom_configuration
? provider.custom_configuration.has_custom_models
: !!provider.custom_configuration.custom_models?.length
const handleAddClick = async () => {
const detail = await loadProviderDetail()
if (!detail) return
if (detail.custom_configuration.can_added_models?.length) {
setIsAddOpen(true)
return
}
handleOpenModelModal(detail, ConfigurationMethodEnum.customizableModel, undefined, {
isModelCredential: true,
mode: ModelModalModeEnum.configCustomModel,
})
}
const handleManageClick = async () => {
const detail = await loadProviderDetail()
if (!detail) return
setIsManageOpen(true)
}
if (providerDetail) {
return (
<>
{hasCustomModels && (
<ManageCustomModelCredentials
provider={providerDetail}
currentCustomConfigurationModelFixedFields={undefined}
isOpen={isManageOpen}
onOpenChange={setIsManageOpen}
/>
)}
<AddCustomModel
provider={providerDetail}
configurationMethod={ConfigurationMethodEnum.customizableModel}
currentCustomConfigurationModelFixedFields={undefined}
open={isAddOpen}
onOpenChange={setIsAddOpen}
/>
</>
)
}
return (
<>
{hasCustomModels && (
<Button
variant="ghost"
size="small"
loading={isLoadingProviderDetail}
onClick={handleManageClick}
className="mr-0.5 text-text-tertiary"
>
{t(($) => $['modelProvider.auth.manageCredentials'], { ns: 'common' })}
</Button>
)}
<Button
variant="ghost"
size="small"
loading={isLoadingProviderDetail}
onClick={handleAddClick}
className="text-text-tertiary"
>
<span className="mr-1 i-ri-add-circle-fill size-3.5" />
{t(($) => $['modelProvider.addModel'], { ns: 'common' })}
</Button>
</>
)
}
@@ -1,9 +1,14 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelProvider } from '../../../declarations'
import type { CredentialPanelState } from '../../use-credential-panel-state'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { commonQueryKeys } from '@/service/use-common'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../../declarations'
import ModelAuthDropdown from '../index'
const render = (ui: React.ReactElement) => renderWithConsoleQuery(ui)
vi.mock('../../../model-auth/hooks', () => ({
useAuth: () => ({
openConfirmDelete: vi.fn(),
@@ -190,6 +195,64 @@ describe('ModelAuthDropdown', () => {
})
describe('Popover behavior', () => {
it('should keep the popover open and allow retrying when loading a summary detail fails', async () => {
const providerSummary = {
provider: 'test',
plugin_id: 'test-plugin',
label: { en_US: 'Test', zh_Hans: 'Test' },
supported_model_types: ['llm'],
configurate_methods: [],
preferred_provider_type: 'system',
is_configured: true,
custom_configuration: {
status: 'active',
has_custom_models: false,
available_credentials: [],
current_credential_usable: false,
},
system_configuration: { enabled: true },
} satisfies ModelProviderSummaryResponse
const fullProvider = createProvider()
render(
<ModelAuthDropdown
provider={providerSummary}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
let resolveFirstRequest: ((response: Response) => void) | undefined
const fetchMock = vi.mocked(globalThis.fetch)
fetchMock.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveFirstRequest = resolve
}),
)
fireEvent.click(screen.getByRole('button', { name: /addApiKey/i }))
expect(await screen.findByRole('status')).toBeInTheDocument()
await act(async () => {
resolveFirstRequest?.(new Response(null, { status: 500 }))
})
expect(await screen.findByRole('alert')).toHaveTextContent('common.api.actionFailed')
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ data: [fullProvider] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' }))
await waitFor(() => {
expect(screen.getByText('common.modelProvider.card.noApiKeysTitle')).toBeInTheDocument()
})
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('should open popover on button click and show dropdown content', async () => {
render(
<ModelAuthDropdown
@@ -213,5 +276,112 @@ describe('ModelAuthDropdown', () => {
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
})
it('should load provider detail on first click and reuse it on reopen', async () => {
const fullProvider = createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: 'c1',
current_credential_name: 'Key 1',
},
})
const providerSummary = {
provider: 'test',
is_configured: true,
custom_configuration: {
available_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: 'c1',
current_credential_name: 'Key 1',
current_credential_usable: true,
},
} as unknown as ModelProviderSummaryResponse
const rendered = render(
<ModelAuthDropdown
provider={providerSummary}
state={createState({ hasCredentials: true, variant: 'api-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
const fetchQuery = vi
.spyOn(rendered.queryClient, 'fetchQuery')
.mockImplementation(async () => {
const response = { data: [fullProvider] }
rendered.queryClient.setQueryData(commonQueryKeys.modelProviderDetails, response)
return response
})
const trigger = screen.getByRole('button', { name: /config/i })
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
expect(fetchQuery).toHaveBeenCalledTimes(1)
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
})
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
expect(fetchQuery).toHaveBeenCalledTimes(1)
})
it('should render updated provider detail from the query cache', async () => {
const providerSummary = {
provider: 'test',
is_configured: true,
custom_configuration: {
available_credentials: [],
current_credential_usable: true,
},
} as unknown as ModelProviderSummaryResponse
const firstProvider = createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: 'c1',
current_credential_name: 'Key 1',
},
})
const nextProvider = createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [{ credential_id: 'c2', credential_name: 'Key 2' }],
current_credential_id: 'c2',
current_credential_name: 'Key 2',
},
})
const rendered = render(
<ModelAuthDropdown
provider={providerSummary}
state={createState({ hasCredentials: true, variant: 'api-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
vi.spyOn(rendered.queryClient, 'fetchQuery').mockImplementation(async () => {
const response = { data: [firstProvider] }
rendered.queryClient.setQueryData(commonQueryKeys.modelProviderDetails, response)
return response
})
fireEvent.click(screen.getByRole('button', { name: /config/i }))
await waitFor(() => expect(screen.getByText('Key 1')).toBeInTheDocument())
rendered.queryClient.setQueryData(commonQueryKeys.modelProviderDetails, {
data: [nextProvider],
})
await waitFor(() => {
expect(screen.getByText('Key 2')).toBeInTheDocument()
})
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
})
})
})
@@ -1,29 +1,26 @@
import type { ModelProviderCreditsResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { ICurrentWorkspace } from '@/models/common'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
import CreditsExhaustedAlert from './credits-exhausted-alert'
const baseWorkspace: ICurrentWorkspace = {
id: 'ws-1',
name: 'Test Workspace',
plan: 'sandbox',
status: 'normal',
created_at: Date.now(),
role: 'owner',
providers: [],
trial_credits: 200,
trial_credits_used: 200,
trial_credits_exhausted_at: 0,
const baseCredits: ModelProviderCreditsResponse = {
pool_type: 'trial',
quota_limit: 200,
quota_used: 200,
remaining_credits: 0,
is_unlimited: false,
is_exhausted: true,
exhausted_at: 0,
next_credit_reset_date: Date.now() + 86400000,
}
function createSeededQueryClient(overrides?: Partial<ICurrentWorkspace>) {
function createSeededQueryClient(overrides?: Partial<ModelProviderCreditsResponse>) {
const qc = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: false } },
})
qc.setQueryData(consoleQuery.workspaces.current.post.queryKey(), {
...baseWorkspace,
qc.setQueryData(consoleQuery.workspaces.current.modelProviders.credits.get.queryKey(), {
...baseCredits,
...overrides,
})
return qc
@@ -74,7 +71,12 @@ export const PartialUsage: Story = {
(Story) => {
return (
<QueryClientProvider
client={createSeededQueryClient({ trial_credits: 500, trial_credits_used: 480 })}
client={createSeededQueryClient({
quota_limit: 500,
quota_used: 480,
remaining_credits: 20,
is_exhausted: false,
})}
>
<div className="w-[320px]">
<Story />
@@ -1,14 +1,16 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelProvider, PreferredProviderTypeEnum } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLazyModelProviderDetail } from '../../hooks'
import { getButtonConfig } from './button-config'
import DropdownContent from './dropdown-content'
type ModelAuthDropdownProps = {
provider: ModelProvider
provider: ModelProviderSummaryResponse | ModelProvider
state: CredentialPanelState
isChangingPriority: boolean
onChangePriority: (key: PreferredProviderTypeEnum) => void
@@ -22,13 +24,41 @@ function ModelAuthDropdown({
}: ModelAuthDropdownProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [isProviderDetailError, setIsProviderDetailError] = useState(false)
const isFullProvider = !('is_configured' in provider) || 'provider_credential_schema' in provider
const { providerDetail, loadProviderDetail, isProviderDetailEnabled, isLoadingProviderDetail } =
useLazyModelProviderDetail(provider.provider)
const currentProvider = isFullProvider ? provider : providerDetail
const handleClose = useCallback(() => setOpen(false), [])
const handleClose = useCallback(() => {
setOpen(false)
setIsProviderDetailError(false)
}, [])
const buttonConfig = getButtonConfig(state.variant, state.hasCredentials, t)
const loadDetail = useCallback(async () => {
setIsProviderDetailError(false)
const detail = await loadProviderDetail()
if (!detail) setIsProviderDetailError(true)
}, [loadProviderDetail])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
handleClose()
return
}
setOpen(true)
if (
!isFullProvider &&
!(currentProvider && isProviderDetailEnabled && !isLoadingProviderDetail)
)
void loadDetail()
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<Button
@@ -36,6 +66,7 @@ function ModelAuthDropdown({
size="small"
variant={buttonConfig.variant}
title={buttonConfig.text}
loading={isLoadingProviderDetail}
>
<span className="i-ri-equalizer-2-line size-3.5 shrink-0" />
<span className="min-w-0 truncate">{buttonConfig.text}</span>
@@ -43,13 +74,38 @@ function ModelAuthDropdown({
}
/>
<PopoverContent placement="bottom-end">
<DropdownContent
provider={provider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={onChangePriority}
onClose={handleClose}
/>
{currentProvider ? (
<DropdownContent
provider={currentProvider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={onChangePriority}
onClose={handleClose}
/>
) : isProviderDetailError ? (
<div className="flex w-80 flex-col items-start gap-3 p-4" role="alert">
<span className="system-sm-medium text-text-primary">
{t(($) => $['api.actionFailed'], { ns: 'common' })}
</span>
<Button size="small" variant="secondary" onClick={() => void loadDetail()}>
{t(($) => $['operation.retry'], { ns: 'common' })}
</Button>
</div>
) : (
<div
className="flex w-80 items-center justify-center gap-2 p-4"
role="status"
aria-busy="true"
>
<span
aria-hidden
className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none"
/>
<span className="system-sm-regular text-text-secondary">
{t(($) => $.loading, { ns: 'common' })}
</span>
</div>
)}
</PopoverContent>
</Popover>
)
@@ -1,3 +1,4 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelItem, ModelProvider } from '../declarations'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
@@ -23,8 +24,10 @@ import ModelName from '../model-name'
type ModelListItemProps = {
model: ModelItem
provider: ModelProvider
provider: ModelProvider | ModelProviderSummaryResponse
isConfigurable: boolean
isLoadingLoadBalancing?: boolean
isLoadBalancingDisabled?: boolean
onChange?: (provider: string) => void
onModifyLoadBalancing?: (model: ModelItem) => void
}
@@ -33,6 +36,8 @@ const ModelListItem = ({
model,
provider,
isConfigurable,
isLoadingLoadBalancing,
isLoadBalancingDisabled,
onChange,
onModifyLoadBalancing,
}: ModelListItemProps) => {
@@ -132,6 +137,8 @@ const ModelListItem = ({
[ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status) && (
<ConfigModel
onClick={() => onModifyLoadBalancing?.(model)}
loading={isLoadingLoadBalancing}
disabled={isLoadBalancingDisabled}
loadBalancingEnabled={model.load_balancing_enabled}
loadBalancingInvalid={model.has_invalid_load_balancing_configs}
credentialRemoved={model.status === ModelStatusEnum.credentialRemoved}
@@ -1,30 +1,69 @@
import type { FC } from 'react'
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ComponentType, FC } from 'react'
import type { Credential, ModelItem, ModelProvider } from '../declarations'
import type { ModelLoadBalancingModalProps } from './model-load-balancing-modal'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
AddCustomModel,
ManageCustomModelCredentials,
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import dynamic from '@/next/dynamic'
import { hasPermission } from '@/utils/permission'
import { ConfigurationMethodEnum } from '../declarations'
import { useLazyModelProviderDetail } from '../hooks'
import LazyCustomModelActions from './lazy-custom-model-actions'
// import Tab from './tab'
import ModelListItem from './model-list-item'
const ModelLoadBalancingModal = dynamic(() => import('./model-load-balancing-modal'), {
ssr: false,
})
const ModelLoadBalancingLoadingDialog = ({
onClose,
}: Pick<ModelLoadBalancingModalProps, 'onClose'>) => {
const { t } = useTranslation()
return (
<Dialog open onOpenChange={(open) => !open && onClose?.()}>
<DialogContent className="w-160 max-w-none border-none px-8 pt-8 text-left align-middle">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['modelProvider.auth.configModel'], { ns: 'common' })}
</DialogTitle>
<div className="flex items-center gap-2 py-8" role="status" aria-busy="true">
<span
aria-hidden
className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none"
/>
<span className="system-sm-regular text-text-secondary">
{t(($) => $.loading, { ns: 'common' })}
</span>
</div>
</DialogContent>
</Dialog>
)
}
type ModelListProps = {
provider: ModelProvider
provider: ModelProvider | ModelProviderSummaryResponse
models: ModelItem[]
onCollapse: () => void
onChange?: (provider: string) => void
}
const getModelKey = (model: ModelItem) => `${model.model}-${model.model_type}-${model.fetch_from}`
let ModelLoadBalancingModal: ComponentType<ModelLoadBalancingModalProps> | undefined
let modelLoadBalancingModalPromise: Promise<void> | undefined
const loadModelLoadBalancingModal = () => {
if (ModelLoadBalancingModal) return Promise.resolve()
modelLoadBalancingModalPromise ??= import('./model-load-balancing-modal').then(
({ default: Modal }) => {
ModelLoadBalancingModal = Modal
},
)
return modelLoadBalancingModalPromise
}
const ModelList: FC<ModelListProps> = ({ provider, models, onCollapse, onChange }) => {
const { t } = useTranslation()
const configurativeMethods = provider.configurate_methods.filter(
@@ -35,17 +74,52 @@ const ModelList: FC<ModelListProps> = ({ provider, models, onCollapse, onChange
const isConfigurable = configurativeMethods.includes(ConfigurationMethodEnum.customizableModel)
const [modelLoadBalancingModalProps, setModelLoadBalancingModalProps] =
useState<ModelLoadBalancingModalProps | null>(null)
const [loadingModelKey, setLoadingModelKey] = useState<string | null>(null)
const [isModelLoadBalancingModalLoading, setIsModelLoadBalancingModalLoading] = useState(false)
const { loadProviderDetail } = useLazyModelProviderDetail(provider.provider)
const onModifyLoadBalancing = useCallback(
(model: ModelItem, credential?: Credential) => {
async (model: ModelItem, credential?: Credential) => {
if (loadingModelKey) return
let providerDetail: ModelProvider | undefined
if ('is_configured' in provider) {
setLoadingModelKey(getModelKey(model))
try {
providerDetail = await loadProviderDetail()
} finally {
setLoadingModelKey(null)
}
} else {
providerDetail = provider
}
if (!providerDetail) {
toast.error(t(($) => $['api.actionFailed'], { ns: 'common' }))
return
}
setModelLoadBalancingModalProps({
provider,
provider: providerDetail,
credential,
configurateMethod: model.fetch_from,
model,
open: true,
onSave: onChange,
})
if (ModelLoadBalancingModal) return
setIsModelLoadBalancingModalLoading(true)
try {
await loadModelLoadBalancingModal()
} catch {
setModelLoadBalancingModalProps(null)
toast.error(t(($) => $['api.actionFailed'], { ns: 'common' }))
} finally {
setIsModelLoadBalancingModalLoading(false)
}
},
[provider],
[loadingModelKey, loadProviderDetail, onChange, provider, t],
)
return (
@@ -60,7 +134,7 @@ const ModelList: FC<ModelListProps> = ({ provider, models, onCollapse, onChange
</span>
<button
type="button"
className="hidden h-6 cursor-pointer items-center rounded-lg border-none bg-state-base-hover pr-1.5 pl-1 system-xs-medium text-text-tertiary group-hover:inline-flex"
className="hidden h-6 cursor-pointer items-center rounded-lg border-none bg-state-base-hover pr-1.5 pl-1 system-xs-medium text-text-tertiary outline-hidden group-hover:inline-flex focus-visible:inline-flex focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => onCollapse()}
>
{t(($) => $['modelProvider.modelsNum'], { ns: 'common', num: models.length })}
@@ -69,25 +143,20 @@ const ModelList: FC<ModelListProps> = ({ provider, models, onCollapse, onChange
</span>
{isConfigurable && canConfigureModels && (
<div className="flex grow justify-end">
<ManageCustomModelCredentials
provider={provider}
currentCustomConfigurationModelFixedFields={undefined}
/>
<AddCustomModel
provider={provider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
currentCustomConfigurationModelFixedFields={undefined}
/>
<LazyCustomModelActions provider={provider} />
</div>
)}
</div>
{models.map((model) => (
<ModelListItem
key={`${model.model}-${model.model_type}-${model.fetch_from}`}
key={getModelKey(model)}
{...{
model,
provider,
isConfigurable,
isLoadingLoadBalancing: loadingModelKey === getModelKey(model),
isLoadBalancingDisabled:
loadingModelKey !== null && loadingModelKey !== getModelKey(model),
onChange,
onModifyLoadBalancing,
}}
@@ -95,11 +164,13 @@ const ModelList: FC<ModelListProps> = ({ provider, models, onCollapse, onChange
))}
</div>
</div>
{modelLoadBalancingModalProps && (
{isModelLoadBalancingModalLoading && modelLoadBalancingModalProps && (
<ModelLoadBalancingLoadingDialog onClose={() => setModelLoadBalancingModalProps(null)} />
)}
{ModelLoadBalancingModal && modelLoadBalancingModalProps && (
<ModelLoadBalancingModal
{...modelLoadBalancingModalProps}
onClose={() => setModelLoadBalancingModalProps(null)}
onSave={onChange}
/>
)}
</>
@@ -1,9 +1,21 @@
import type { FC } from 'react'
import type { ModelProviderPluginSummary } from '../index'
import type { PluginDetail } from '@/app/components/plugins/types'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { HeaderModals } from '@/app/components/plugins/plugin-detail-panel/detail-header/components'
@@ -17,24 +29,272 @@ import { PluginSource } from '@/app/components/plugins/types'
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { consoleQuery } from '@/service/client'
import { uninstallPlugin } from '@/service/plugins'
import { commonQueryKeys } from '@/service/use-common'
import { normalizeInstalledPluginDetail } from '@/service/use-plugins'
import { getMarketplaceUrl } from '@/utils/var'
type Props = Readonly<{
type DetailAction = 'version' | 'latest' | 'info' | 'check'
type Props =
| Readonly<{
summary: ModelProviderPluginSummary
providerLabel: string
onUpdate?: () => void
detail?: never
}>
| Readonly<{
detail: PluginDetail
onUpdate?: () => void
summary?: never
providerLabel?: never
}>
const pluginSourceMap: Record<ModelProviderPluginSummary['source'], PluginSource> = {
github: PluginSource.github,
marketplace: PluginSource.marketplace,
package: PluginSource.local,
remote: PluginSource.debugging,
}
const ProviderCardActions: FC<Props> = (props) => {
const queryClient = useQueryClient()
const { onUpdate } = props
const handlePluginChanged = useCallback(async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
}),
queryClient.invalidateQueries({
queryKey: consoleQuery.workspaces.current.plugin.installedIds.get.key(),
}),
queryClient.invalidateQueries({
queryKey: consoleQuery.workspaces.current.plugin.list.installations.ids.post.key(),
}),
queryClient.invalidateQueries({
queryKey: consoleQuery.workspaces.current.plugin.list.latestVersions.post.key(),
}),
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails }),
queryClient.invalidateQueries({ queryKey: ['marketplacePlugins'] }),
queryClient.invalidateQueries({ queryKey: ['marketplaceCollectionPlugins'] }),
])
onUpdate?.()
}, [onUpdate, queryClient])
if (props.detail) {
return (
<LoadedProviderCardActions
detail={props.detail}
onInitialActionHandled={() => {}}
onUpdate={handlePluginChanged}
/>
)
}
return <SummaryProviderCardActions {...props} onUpdate={handlePluginChanged} />
}
type SummaryProps = Extract<Props, { summary: ModelProviderPluginSummary }>
function SummaryProviderCardActions({ summary, providerLabel, onUpdate }: SummaryProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { canDeletePlugin, canUpdatePlugin } = usePluginSettingsAccess()
const [detail, setDetail] = useState<PluginDetail>()
const [detailAction, setDetailAction] = useState<DetailAction>()
const [loadingAction, setLoadingAction] = useState<DetailAction>()
const [showDeleteConfirm, { setTrue: openDeleteConfirm, setFalse: closeDeleteConfirm }] =
useBoolean(false)
const [deleting, { setTrue: startDeleting, setFalse: finishDeleting }] = useBoolean(false)
const source = pluginSourceMap[summary.source]
const isFromMarketplace = source === PluginSource.marketplace
const isFromGitHub = source === PluginSource.github
const canChangeVersion = canUpdatePlugin && isFromMarketplace
const hasNewVersion =
isFromMarketplace && !!summary.latestVersion && summary.latestVersion !== summary.version
const [author, name] = summary.plugin_id.split('/')
const detailUrl =
isFromMarketplace && author && name ? getMarketplaceUrl(`/plugins/${author}/${name}`) : ''
const loadDetail = async (action: DetailAction) => {
setLoadingAction(action)
try {
const response = await queryClient.fetchQuery(
consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({
input: { body: { plugin_ids: [summary.plugin_id] } },
}),
)
const nextDetail = response.plugins[0]
if (!nextDetail) return
const normalizedDetail = normalizeInstalledPluginDetail(nextDetail)
const detailWithLatestVersion =
isFromMarketplace && summary.latestVersion && summary.latestUniqueIdentifier
? {
...normalizedDetail,
latest_version: summary.latestVersion,
latest_unique_identifier: summary.latestUniqueIdentifier,
}
: normalizedDetail
setDetail(detailWithLatestVersion)
setDetailAction(action)
} catch {
} finally {
setLoadingAction(undefined)
}
}
const handleDelete = async () => {
startDeleting()
try {
const response = await uninstallPlugin(summary.installation_id)
if (!response.success) return
closeDeleteConfirm()
await onUpdate?.()
} finally {
finishDeleting()
}
}
if (detail) {
return (
<LoadedProviderCardActions
detail={detail}
initialAction={detailAction}
onInitialActionHandled={() => setDetailAction(undefined)}
onUpdate={onUpdate}
/>
)
}
return (
<>
{!!summary.version && (
<>
{canChangeVersion ? (
<button
type="button"
className="rounded-md border-0 bg-transparent p-0 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
aria-label={summary.version}
disabled={loadingAction === 'version'}
onClick={() => loadDetail('version')}
>
<Badge
className="cursor-pointer hover:bg-state-base-hover"
uppercase={false}
text={
<>
<span>{summary.version}</span>
<span className="ml-1 i-ri-arrow-left-right-line size-3" />
</>
}
hasRedCornerMark={hasNewVersion}
/>
</button>
) : (
<Badge
uppercase={false}
text={<span>{summary.version}</span>}
hasRedCornerMark={hasNewVersion}
/>
)}
{source === PluginSource.debugging && (
<Badge
className="border-state-warning-active bg-state-warning-hover text-text-warning"
size="xs"
uppercase={false}
text={t(($) => $['operation.debugConfig'], { ns: 'appDebug' })}
/>
)}
</>
)}
{canUpdatePlugin && (hasNewVersion || isFromGitHub) && (
<Tooltip>
<TooltipTrigger
delay={300}
render={
<Button
variant="secondary-accent"
size="small"
className="h-5!"
loading={loadingAction === 'latest'}
onClick={() => loadDetail('latest')}
>
{t(($) => $['detailPanel.operation.update'], { ns: 'plugin' })}
</Button>
}
/>
<TooltipContent>
{t(($) => $['detailPanel.operation.updateTooltip'], { ns: 'plugin' })}
</TooltipContent>
</Tooltip>
)}
<OperationDropdown
source={source}
onInfo={() => loadDetail('info')}
onCheckVersion={() => loadDetail('check')}
onRemove={openDeleteConfirm}
detailUrl={detailUrl}
placement="bottom-start"
destructiveRemove
showCheckVersion={canUpdatePlugin}
showRemove={canDeletePlugin}
/>
<AlertDialog
open={showDeleteConfirm}
onOpenChange={(open) => {
if (!open) closeDeleteConfirm()
}}
>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['action.delete'], { ns: 'plugin' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['action.deleteContentLeft'], { ns: 'plugin' })}
<span className="system-md-semibold text-text-secondary">{providerLabel}</span>
{t(($) => $['action.deleteContentRight'], { ns: 'plugin' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={deleting}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
type LoadedProps = Readonly<{
detail: PluginDetail
onUpdate?: () => void | Promise<void>
initialAction?: DetailAction
onInitialActionHandled: () => void
onUpdate?: () => void
}>
const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
function LoadedProviderCardActions({
detail,
initialAction,
onInitialActionHandled,
onUpdate,
}: LoadedProps) {
const { t } = useTranslation()
const { theme } = useTheme()
const locale = useLocale()
const { canDeletePlugin, canUpdatePlugin } = usePluginSettingsAccess()
const { source, version, latest_version, latest_unique_identifier, meta } = detail
const author = detail.declaration?.author ?? ''
const name = detail.declaration?.name ?? detail.name
const isDebuggingPlugin = source === PluginSource.debugging
const {
modalStates,
versionPicker,
@@ -43,7 +303,6 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
isFromMarketplace,
isFromGitHub,
} = useDetailHeaderState(detail)
const { handleUpdate, handleUpdatedFromMarketplace, handleDelete } = usePluginOperations({
detail,
modalStates,
@@ -63,15 +322,28 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
handleUpdate(state.isDowngrade)
}
const handleTriggerLatestUpdate = () => {
const handleTriggerLatestUpdate = useCallback(() => {
if (isFromMarketplace) {
if (!latest_unique_identifier) return
versionPicker.setTargetVersion({
version: latest_version,
unique_identifier: latest_unique_identifier,
})
}
handleUpdate()
}
}, [handleUpdate, isFromMarketplace, latest_unique_identifier, latest_version, versionPicker])
const pendingInitialActionRef = useRef(initialAction)
useEffect(() => {
const pendingAction = pendingInitialActionRef.current
if (!pendingAction) return
pendingInitialActionRef.current = undefined
if (pendingAction === 'version') versionPicker.setIsShow(true)
if (pendingAction === 'latest') handleTriggerLatestUpdate()
if (pendingAction === 'info') modalStates.showPluginInfo()
if (pendingAction === 'check') handleUpdate()
onInitialActionHandled()
}, [handleTriggerLatestUpdate, handleUpdate, modalStates, onInitialActionHandled, versionPicker])
const detailUrl = useMemo(() => {
if (source === PluginSource.github) return meta?.repo ? `https://github.com/${meta.repo}` : ''
@@ -1,6 +1,7 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { FC, MouseEvent } from 'react'
import type { ModelProvider } from '../declarations'
import type { Plugin } from '@/app/components/plugins/types'
import type { PluginManifestInMarket } from '@/app/components/plugins/types'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
@@ -17,9 +18,9 @@ import InstallFromMarketplace from '@/app/components/plugins/install-plugin/inst
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/client'
import { fetchManifestFromMarketPlace, fetchPluginInfoFromMarketPlace } from '@/service/plugins'
import { formatNumber } from '@/utils/format'
import { PreferredProviderTypeEnum } from '../declarations'
import { useMarketplaceAllPlugins } from '../hooks'
import {
MODEL_PROVIDER_QUOTA_GET_PAID,
modelNameMap,
@@ -69,16 +70,29 @@ const QuotaInfotip: FC<QuotaInfotipProps> = ({ tipText }) => {
}
type QuotaPanelProps = {
providers: ModelProvider[]
providers: Array<ModelProviderSummaryResponse | ModelProvider>
}
type MarketplacePluginToInstall = {
manifest: PluginManifestInMarket
uniqueIdentifier: string
}
const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
const { t } = useTranslation()
const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const { usedCredits, totalCredits, isExhausted, isLoading, exhaustedAt, nextCreditResetDate } =
useTrialCredits()
const {
usedCredits,
totalCredits,
isUnlimited,
isExhausted,
isLoading,
exhaustedAt,
nextCreditResetDate,
} = useTrialCredits()
const { data: trialModels = [] } = useQuery(
consoleQuery.trialModels.get.queryOptions({
enabled: deploymentEdition === 'CLOUD',
@@ -94,8 +108,8 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
[providers],
)
const { formatMonthDay } = useTimestamp()
const { plugins: allPlugins } = useMarketplaceAllPlugins(providers, '')
const [selectedPlugin, setSelectedPlugin] = useState<Plugin | null>(null)
const [selectedPlugin, setSelectedPlugin] = useState<MarketplacePluginToInstall | null>(null)
const [loadingPluginId, setLoadingPluginId] = useState<string | null>(null)
const [
isShowInstallModal,
{ setTrue: showInstallFromMarketplace, setFalse: hideInstallFromMarketplace },
@@ -105,19 +119,41 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
const selectedPluginIdRef = useRef<string | null>(null)
const handleIconClick = useCallback(
(key: ModelProviderQuotaGetPaid) => {
async (key: ModelProviderQuotaGetPaid) => {
if (loadingPluginId) return
const isInstalled = providerMap.get(key)
if (!isInstalled && allPlugins && canInstallPlugin) {
if (!isInstalled && canInstallPlugin) {
const pluginId = providerKeyToPluginId[key]
const plugin = allPlugins.find((p) => p.plugin_id === pluginId)
if (plugin) {
setSelectedPlugin(plugin)
const [org, name] = pluginId.split('/')
if (!org || !name) return
setLoadingPluginId(pluginId)
try {
const pluginInfo = await fetchPluginInfoFromMarketPlace({ org, name })
const uniqueIdentifier = pluginInfo.data.plugin.latest_package_identifier
if (!uniqueIdentifier) return
const manifest = await fetchManifestFromMarketPlace(uniqueIdentifier)
setSelectedPlugin({
manifest: {
...manifest.data.plugin,
org,
name,
from: 'marketplace',
icon: manifest.data.plugin.icon || 'marketplace',
},
uniqueIdentifier,
})
selectedPluginIdRef.current = pluginId
showInstallFromMarketplace()
} catch {
// Keep the provider actionable so the user can retry the marketplace request.
} finally {
setLoadingPluginId(null)
}
}
},
[allPlugins, canInstallPlugin, providerMap, showInstallFromMarketplace],
[canInstallPlugin, loadingPluginId, providerMap, showInstallFromMarketplace],
)
useEffect(() => {
@@ -166,16 +202,24 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
<div className="flex h-6 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<div className="flex shrink-0 items-baseline gap-1">
<span className={cn('system-xl-semibold', creditUsageTextClassName)}>
{formatNumber(usedCredits)}
</span>
<span className="text-base leading-6 font-normal text-text-tertiary">/</span>
<span className={cn('system-xl-semibold', creditUsageTextClassName)}>
{formatNumber(totalCredits)}
</span>
<span className={cn('system-md-medium', creditUsageTextClassName)}>
{t(($) => $['modelProvider.used'], { ns: 'common' })}
</span>
{isUnlimited ? (
<span className="system-xl-semibold text-text-secondary">
{t(($) => $['license.unlimited'], { ns: 'common' })}
</span>
) : (
<>
<span className={cn('system-xl-semibold', creditUsageTextClassName)}>
{formatNumber(usedCredits)}
</span>
<span className="text-base leading-6 font-normal text-text-tertiary">/</span>
<span className={cn('system-xl-semibold', creditUsageTextClassName)}>
{formatNumber(totalCredits)}
</span>
<span className={cn('system-md-medium', creditUsageTextClassName)}>
{t(($) => $['modelProvider.used'], { ns: 'common' })}
</span>
</>
)}
</div>
{isExhausted && exhaustedAt ? (
<>
@@ -211,6 +255,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
.filter(({ key }) => trialModels.includes(key))
.map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const isLoadingPlugin = loadingPluginId === providerKeyToPluginId[key]
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0
const getTooltipKey = () => {
if (!providerType) return 'modelProvider.card.modelNotSupported'
@@ -229,13 +274,21 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
render={
<button
type="button"
aria-busy={isLoadingPlugin}
aria-disabled={isLoadingPlugin}
className={cn(
'relative size-6 border-0 bg-transparent p-0',
'relative size-6 border-0 bg-transparent p-0 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
!providerType && canInstallPlugin && 'cursor-pointer hover:opacity-80',
)}
onClick={() => handleIconClick(key)}
>
<Icon className="size-6 rounded-lg" />
{isLoadingPlugin && (
<span
aria-hidden
className="absolute inset-1 i-ri-loader-2-line animate-spin text-text-accent"
/>
)}
{!providerType && (
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
)}
@@ -256,8 +309,8 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
currentDifyVersion={currentDifyVersion}
>
<InstallFromMarketplace
manifest={selectedPlugin}
uniqueIdentifier={selectedPlugin.latest_package_identifier}
manifest={selectedPlugin.manifest}
uniqueIdentifier={selectedPlugin.uniqueIdentifier}
onClose={hideInstallFromMarketplace}
onSuccess={hideInstallFromMarketplace}
/>
@@ -1,3 +1,4 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelProvider, PreferredProviderTypeEnum } from '../declarations'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
@@ -6,7 +7,9 @@ import { consoleQuery } from '@/service/client'
import { ConfigurationMethodEnum } from '../declarations'
import { useUpdateModelList, useUpdateModelProviders } from '../hooks'
export function useChangeProviderPriority(provider: ModelProvider | undefined) {
export function useChangeProviderPriority(
provider: ModelProvider | ModelProviderSummaryResponse | undefined,
) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
@@ -1,3 +1,4 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModelProvider } from '../declarations'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
@@ -64,7 +65,9 @@ function deriveVariant(
return 'api-required-add'
}
export function useCredentialPanelState(provider: ModelProvider | undefined): CredentialPanelState {
export function useCredentialPanelState(
provider: ModelProvider | ModelProviderSummaryResponse | undefined,
): CredentialPanelState {
const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
@@ -1,31 +1,22 @@
import type { ModelProviderCreditsResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { useQuery } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
const selectTrialCredits = (workspace: {
trial_credits?: number | null
trial_credits_used?: number | null
trial_credits_exhausted_at?: number | null
next_credit_reset_date?: number | null
}) => {
const totalCredits = Math.max(workspace.trial_credits ?? 0, 0)
const rawUsedCredits = workspace.trial_credits_used ?? 0
const normalizedUsedCredits = Math.max(rawUsedCredits, 0)
const usedCredits = Math.min(normalizedUsedCredits, totalCredits)
const credits = Math.max(totalCredits - usedCredits, 0)
const selectTrialCredits = (creditPool: ModelProviderCreditsResponse) => {
return {
credits,
usedCredits,
totalCredits,
isExhausted: credits <= 0,
exhaustedAt: workspace.trial_credits_exhausted_at ?? undefined,
nextCreditResetDate: workspace.next_credit_reset_date ?? undefined,
credits: creditPool.remaining_credits ?? 0,
usedCredits: creditPool.quota_used ?? 0,
totalCredits: creditPool.quota_limit ?? 0,
isUnlimited: creditPool.is_unlimited,
isExhausted: creditPool.is_exhausted,
exhaustedAt: creditPool.exhausted_at ?? undefined,
nextCreditResetDate: creditPool.next_credit_reset_date ?? undefined,
}
}
export const useTrialCredits = () => {
const trialCreditsQuery = useQuery(
consoleQuery.workspaces.current.post.queryOptions({
consoleQuery.workspaces.current.modelProviders.credits.get.queryOptions({
select: selectTrialCredits,
}),
)
@@ -35,6 +26,7 @@ export const useTrialCredits = () => {
credits: trialCredits?.credits ?? 0,
usedCredits: trialCredits?.usedCredits ?? 0,
totalCredits: trialCredits?.totalCredits ?? 0,
isUnlimited: trialCredits?.isUnlimited ?? false,
isExhausted: trialCredits?.isExhausted ?? true,
isLoading: trialCreditsQuery.isPending && !trialCredits,
exhaustedAt: trialCredits?.exhaustedAt,
@@ -1,5 +1,5 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { FC } from 'react'
import type { ModelProvider } from '../declarations'
import { cn } from '@langgenius/dify-ui/cn'
import { AnthropicDark, AnthropicLight } from '@/app/components/base/icons/src/public/llm'
import { Openai } from '@/app/components/base/icons/src/vender/other'
@@ -9,13 +9,16 @@ import { Theme } from '@/types/app'
import { useLanguage } from '../hooks'
type ProviderIconProps = {
provider: ModelProvider
provider: Pick<
ModelProviderSummaryResponse,
'provider' | 'icon_small' | 'icon_small_dark' | 'label'
>
className?: string
}
const ProviderIcon: FC<ProviderIconProps> = ({ provider, className }) => {
const { theme } = useTheme()
const language = useLanguage()
const lightIconUrl = renderI18nObject(provider.icon_small, language)
const lightIconUrl = provider.icon_small ? renderI18nObject(provider.icon_small, language) : ''
const darkIconUrl = provider.icon_small_dark
? renderI18nObject(provider.icon_small_dark, language)
: ''
@@ -41,7 +44,15 @@ const ProviderIcon: FC<ProviderIconProps> = ({ provider, className }) => {
return (
<div className={cn('inline-flex items-center gap-2', className)}>
{iconUrl ? (
<img alt="provider-icon" src={iconUrl} className="size-6" />
<img
alt=""
className="size-6"
decoding="async"
height={24}
loading="lazy"
src={iconUrl}
width={24}
/>
) : (
<div className="flex size-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span aria-hidden className="i-custom-vender-other-group size-4 text-text-tertiary" />
@@ -1,7 +1,7 @@
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import type { ModelProvider } from './declarations'
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
type CreditAwareProvider = Pick<ModelProvider, 'provider' | 'system_configuration'>
type CreditAwareProvider = Pick<ModelProviderSummaryResponse, 'provider' | 'system_configuration'>
export const providerSupportsCredits = (
provider: CreditAwareProvider | undefined,
@@ -1,5 +1,6 @@
import type { DefaultModelResponse } from '../../declarations'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { render } from '@/test/console/render'
import { ModelTypeEnum } from '../../declarations'
@@ -23,6 +24,7 @@ vi.mock('react-i18next', async () => {
'modelProvider.ttsModel.tip': 'TTS model tip',
'operation.cancel': 'Cancel',
'operation.save': 'Save',
loading: 'Loading',
'actionMsg.modifiedSuccessfully': 'Modified successfully',
})
})
@@ -31,6 +33,7 @@ const mockToastSuccess = vi.hoisted(() => vi.fn())
const mockUpdateModelList = vi.hoisted(() => vi.fn())
const mockInvalidateDefaultModel = vi.hoisted(() => vi.fn())
const mockUpdateDefaultModel = vi.hoisted(() => vi.fn(() => Promise.resolve({ result: 'success' })))
const mockUseModelList = vi.hoisted(() => vi.fn())
const mockModelSelectorProps = vi.hoisted(
() =>
[] as Array<{
@@ -67,9 +70,7 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
})
vi.mock('../../hooks', () => ({
useModelList: () => ({
data: [],
}),
useModelList: mockUseModelList,
useSystemDefaultModelAndModelList: (defaultModel: DefaultModelResponse | undefined) => [
defaultModel || {
model: '',
@@ -128,6 +129,7 @@ const defaultProps = {
describe('SystemModel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseModelList.mockReturnValue({ data: [], isLoading: false })
mockModelSelectorProps.length = 0
mockWorkspacePermissionKeys = ['plugin.model_config']
})
@@ -146,6 +148,41 @@ describe('SystemModel', () => {
})
})
it('loads non-text model lists only after the dialog opens', async () => {
const user = userEvent.setup()
render(<SystemModel {...defaultProps} />)
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding, { enabled: false })
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.rerank, { enabled: false })
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.speech2text, { enabled: false })
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.tts, { enabled: false })
await user.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding, { enabled: true })
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.rerank, { enabled: true })
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.speech2text, { enabled: true })
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.tts, { enabled: true })
})
})
it('shows loading instead of empty model selectors while model lists load', async () => {
const user = userEvent.setup()
mockUseModelList.mockReturnValue({ data: [], isLoading: true })
render(<SystemModel {...defaultProps} />)
await user.click(screen.getByRole('button', { name: /system model settings/i }))
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Mock Model Selector' })).not.toBeInTheDocument()
const saveButton = screen.getByRole('button', { name: /save/i })
expect(saveButton).toBeDisabled()
await user.click(saveButton)
expect(mockUpdateDefaultModel).not.toHaveBeenCalled()
})
it('should disable button when loading', () => {
render(<SystemModel {...defaultProps} isLoading />)
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
@@ -66,10 +66,22 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
const canManageSystemDefaultModel = hasPermission(workspacePermissionKeys, 'plugin.model_config')
const updateModelList = useUpdateModelList()
const invalidateDefaultModel = useInvalidateDefaultModel()
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const { data: speech2textModelList } = useModelList(ModelTypeEnum.speech2text)
const { data: ttsModelList } = useModelList(ModelTypeEnum.tts)
const [open, setOpen] = useState(false)
const { data: embeddingModelList, isLoading: isEmbeddingModelListLoading } = useModelList(
ModelTypeEnum.textEmbedding,
{ enabled: open },
)
const { data: rerankModelList, isLoading: isRerankModelListLoading } = useModelList(
ModelTypeEnum.rerank,
{ enabled: open },
)
const { data: speech2textModelList, isLoading: isSpeech2textModelListLoading } = useModelList(
ModelTypeEnum.speech2text,
{ enabled: open },
)
const { data: ttsModelList, isLoading: isTTSModelListLoading } = useModelList(ModelTypeEnum.tts, {
enabled: open,
})
const [changedModelTypes, setChangedModelTypes] = useState<ModelTypeEnum[]>([])
const [currentTextGenerationDefaultModel, changeCurrentTextGenerationDefaultModel] =
useSystemDefaultModelAndModelList(textGenerationDefaultModel, textGenerationModelList)
@@ -83,7 +95,12 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
ttsDefaultModel,
ttsModelList,
)
const [open, setOpen] = useState(false)
const isSystemModelListLoading =
open &&
(isEmbeddingModelListLoading ||
isRerankModelListLoading ||
isSpeech2textModelListLoading ||
isTTSModelListLoading)
const getCurrentDefaultModelByModelType = (modelType: ModelTypeEnum) => {
if (modelType === ModelTypeEnum.textGeneration) return currentTextGenerationDefaultModel
@@ -105,6 +122,8 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
setChangedModelTypes([...changedModelTypes, modelType])
}
const handleSave = async () => {
if (isSystemModelListLoading) return
const res = await updateDefaultModel({
url: '/workspaces/current/default-model',
body: {
@@ -187,91 +206,109 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
{renderModelLabel(
'modelProvider.systemReasoningModel.key',
'modelProvider.systemReasoningModel.tip',
)}
<div>
<ModelSelector
defaultModel={currentTextGenerationDefaultModel}
modelList={textGenerationModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) =>
handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)
}
/>
{isSystemModelListLoading ? (
<div
role="status"
aria-label={t(($) => $.loading, { ns: 'common' })}
className="flex h-full min-h-48 items-center justify-center"
>
<span
aria-hidden
className="i-ri-loader-2-line size-5 animate-spin text-text-tertiary"
/>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
{renderModelLabel(
'modelProvider.systemReasoningModel.key',
'modelProvider.systemReasoningModel.tip',
)}
<div>
<ModelSelector
defaultModel={currentTextGenerationDefaultModel}
modelList={textGenerationModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) =>
handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)
}
/>
</div>
</div>
<div className="flex flex-col gap-1">
{renderModelLabel(
'modelProvider.embeddingModel.key',
'modelProvider.embeddingModel.tip',
)}
<div>
<ModelSelector
defaultModel={currentEmbeddingsDefaultModel}
modelList={embeddingModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) =>
handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)
}
/>
</div>
</div>
<div className="flex flex-col gap-1">
{renderModelLabel(
'modelProvider.rerankModel.key',
'modelProvider.rerankModel.tip',
)}
<div>
<ModelSelector
defaultModel={currentRerankDefaultModel}
modelList={rerankModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
/>
</div>
</div>
<div className="flex flex-col gap-1">
{renderModelLabel(
'modelProvider.speechToTextModel.key',
'modelProvider.speechToTextModel.tip',
)}
<div>
<ModelSelector
defaultModel={currentSpeech2textDefaultModel}
modelList={speech2textModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) =>
handleChangeDefaultModel(ModelTypeEnum.speech2text, model)
}
/>
</div>
</div>
<div className="flex flex-col gap-1">
{renderModelLabel('modelProvider.ttsModel.key', 'modelProvider.ttsModel.tip')}
<div>
<ModelSelector
defaultModel={currentTTSDefaultModel}
modelList={ttsModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-1">
{renderModelLabel(
'modelProvider.embeddingModel.key',
'modelProvider.embeddingModel.tip',
)}
<div>
<ModelSelector
defaultModel={currentEmbeddingsDefaultModel}
modelList={embeddingModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) =>
handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)
}
/>
</div>
</div>
<div className="flex flex-col gap-1">
{renderModelLabel('modelProvider.rerankModel.key', 'modelProvider.rerankModel.tip')}
<div>
<ModelSelector
defaultModel={currentRerankDefaultModel}
modelList={rerankModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
/>
</div>
</div>
<div className="flex flex-col gap-1">
{renderModelLabel(
'modelProvider.speechToTextModel.key',
'modelProvider.speechToTextModel.tip',
)}
<div>
<ModelSelector
defaultModel={currentSpeech2textDefaultModel}
modelList={speech2textModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.speech2text, model)}
/>
</div>
</div>
<div className="flex flex-col gap-1">
{renderModelLabel('modelProvider.ttsModel.key', 'modelProvider.ttsModel.tip')}
<div>
<ModelSelector
defaultModel={currentTTSDefaultModel}
modelList={ttsModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
/>
</div>
</div>
</div>
)}
</div>
<div className="flex h-19 shrink-0 items-center justify-end gap-2 px-6 pt-5 pb-6">
<Button className="min-w-18" onClick={() => setOpen(false)}>
@@ -281,7 +318,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
className="min-w-18"
variant="primary"
onClick={handleSave}
disabled={!canManageSystemDefaultModel}
disabled={!canManageSystemDefaultModel || isSystemModelListLoading}
>
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
@@ -32,11 +32,6 @@ import {
export { ModelProviderQuotaGetPaid } from '@/types/model-provider'
export const providerToPluginId = (providerKey: string): string => {
const lastSlash = providerKey.lastIndexOf('/')
return lastSlash > 0 ? providerKey.slice(0, lastSlash) : ''
}
export const MODEL_PROVIDER_QUOTA_GET_PAID = [
ModelProviderQuotaGetPaid.OPENAI,
ModelProviderQuotaGetPaid.ANTHROPIC,
@@ -86,7 +81,7 @@ export const sizeFormat = (size: number) => {
else return `${remainder}K`
}
export const modelTypeFormat = (modelType: ModelTypeEnum) => {
export const modelTypeFormat = (modelType: ModelTypeEnum | ModelType) => {
if (modelType === ModelTypeEnum.textEmbedding) return 'TEXT EMBEDDING'
return modelType.toLocaleUpperCase()
@@ -94,8 +94,14 @@ let mockCollectionData: ReturnType<typeof createDefaultCollections> = []
let mockIsLoadingToolProviders = false
const mockRefetch = vi.fn()
const mockUseAllToolProviders = vi.hoisted(() => vi.fn())
const mockUseAllCustomTools = vi.hoisted(() => vi.fn())
const mockUseAllMCPTools = vi.hoisted(() => vi.fn())
const mockUseAllWorkflowTools = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: (enabled?: boolean) => mockUseAllToolProviders(enabled),
useAllCustomTools: (enabled?: boolean) => mockUseAllCustomTools(enabled),
useAllMCPTools: (enabled?: boolean) => mockUseAllMCPTools(enabled),
useAllWorkflowTools: (enabled?: boolean) => mockUseAllWorkflowTools(enabled),
}))
const mockConsoleState = vi.hoisted(() => ({
@@ -386,6 +392,23 @@ describe('ProviderList', () => {
isLoading: enabled ? mockIsLoadingToolProviders : false,
refetch: mockRefetch,
}))
mockUseAllCustomTools.mockImplementation((enabled = true) => ({
data: enabled ? mockCollectionData.filter((collection) => collection.type === 'api') : [],
isLoading: enabled ? mockIsLoadingToolProviders : false,
refetch: mockRefetch,
}))
mockUseAllMCPTools.mockImplementation((enabled = true) => ({
data: enabled ? mockCollectionData.filter((collection) => collection.type === 'mcp') : [],
isLoading: enabled ? mockIsLoadingToolProviders : false,
refetch: mockRefetch,
}))
mockUseAllWorkflowTools.mockImplementation((enabled = true) => ({
data: enabled
? mockCollectionData.filter((collection) => collection.type === 'workflow')
: [],
isLoading: enabled ? mockIsLoadingToolProviders : false,
refetch: mockRefetch,
}))
mockCheckedInstalledData = null
mockCanSetPermissions.mockReturnValue(true)
mockReferenceSetting.mockReturnValue({
@@ -447,7 +470,10 @@ describe('ProviderList', () => {
renderProviderList({ category })
expect(mockUseAllToolProviders).toHaveBeenCalledWith(undefined)
expect(mockUseAllToolProviders).toHaveBeenCalledWith(false)
expect(mockUseAllCustomTools).toHaveBeenCalledWith(category === 'api')
expect(mockUseAllWorkflowTools).toHaveBeenCalledWith(category === 'workflow')
expect(mockUseAllMCPTools).toHaveBeenCalledWith(false)
expect(screen.getByTestId(cardTestId)).toBeInTheDocument()
expect(screen.queryByTestId('custom-create-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('toolbar-add-custom-tool')).not.toBeInTheDocument()
@@ -940,7 +966,10 @@ describe('ProviderList', () => {
renderProviderList({ category: 'mcp' })
expect(mockUseAllToolProviders).toHaveBeenCalledWith(undefined)
expect(mockUseAllToolProviders).toHaveBeenCalledWith(false)
expect(mockUseAllCustomTools).toHaveBeenCalledWith(false)
expect(mockUseAllWorkflowTools).toHaveBeenCalledWith(false)
expect(mockUseAllMCPTools).toHaveBeenCalledWith(true)
expect(screen.getByTestId('mcp-list')).toBeInTheDocument()
expect(screen.getByTestId('mcp-list')).toHaveAttribute('data-show-create-card', 'false')
expect(screen.queryByTestId('toolbar-add-mcp')).not.toBeInTheDocument()
@@ -1,5 +1,5 @@
'use client'
import type { ReactNode, RefObject } from 'react'
import type { ReactNode } from 'react'
import type { ToolCategory } from '@/app/components/integrations/routes'
import type { ToolsContentInset } from '@/app/components/tools/content-inset'
import type { Collection } from '@/app/components/tools/types'
@@ -28,14 +28,18 @@ import {
useCanManageMCP,
useCanManageTools,
} from '@/app/components/tools/hooks/use-tool-permissions'
import Marketplace from '@/app/components/tools/marketplace'
import { BuiltinMarketplacePanel } from '@/app/components/tools/marketplace/builtin-marketplace-panel'
import MCPList from '@/app/components/tools/mcp'
import ProviderDetail from '@/app/components/tools/provider/detail'
import { ToolProviderGrid } from '@/app/components/tools/tool-provider-grid'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useAllToolProviders } from '@/service/use-tools'
import { useToolMarketplacePanel } from './hooks/use-tool-marketplace-panel'
import {
useAllCustomTools,
useAllMCPTools,
useAllToolProviders,
useAllWorkflowTools,
} from '@/service/use-tools'
import { useToolProviderCategory } from './hooks/use-tool-provider-category'
import ToolProviderCreateAction from './tool-provider-create-action'
import { ToolProviderToolbar } from './tool-provider-toolbar'
@@ -46,40 +50,7 @@ type ProviderListProps = {
layout?: (parts: { body: ReactNode; toolbar: ReactNode }) => ReactNode
}
type BuiltinMarketplacePanelProps = {
containerRef: RefObject<HTMLDivElement | null>
contentInset: ToolsContentInset
keywords: string
tagFilterValue: string[]
}
const BuiltinMarketplacePanel = ({
containerRef,
contentInset,
keywords,
tagFilterValue,
}: BuiltinMarketplacePanelProps) => {
const { isMarketplaceArrowVisible, marketplaceContext, showMarketplacePanel, toolListTailRef } =
useToolMarketplacePanel({
containerRef,
keywords,
tagFilterValue,
})
return (
<>
<div ref={toolListTailRef} />
<Marketplace
searchPluginText={keywords}
filterPluginTags={tagFilterValue}
isMarketplaceArrowVisible={isMarketplaceArrowVisible}
showMarketplacePanel={showMarketplacePanel}
marketplaceContext={marketplaceContext}
contentInset={contentInset}
/>
</>
)
}
const EMPTY_COLLECTIONS: Collection[] = []
const ProviderList = ({ category, contentInset = 'default', layout }: ProviderListProps) => {
// const searchParams = useSearchParams()
@@ -119,11 +90,25 @@ const ProviderList = ({ category, contentInset = 'default', layout }: ProviderLi
const handleCreatedMCPProviderHandled = useCallback(() => {
setCreatedMCPProviderId(undefined)
}, [])
const {
data: collectionList = [],
isLoading: isCollectionListLoading,
refetch,
} = useAllToolProviders()
const allToolProvidersQuery = useAllToolProviders(activeTab === 'builtin')
const customToolsQuery = useAllCustomTools(activeTab === 'api')
const workflowToolsQuery = useAllWorkflowTools(activeTab === 'workflow')
const mcpToolsQuery = useAllMCPTools(activeTab === 'mcp')
const { refetch: refetchMcpTools } = mcpToolsQuery
const activeToolsQuery =
activeTab === 'api'
? customToolsQuery
: activeTab === 'workflow'
? workflowToolsQuery
: activeTab === 'mcp'
? mcpToolsQuery
: allToolProvidersQuery
const collectionList = activeToolsQuery.data ?? EMPTY_COLLECTIONS
const isCollectionListLoading = activeToolsQuery.isLoading
const refetch = activeToolsQuery.refetch
const refreshMcpTools = useCallback(async () => {
await refetchMcpTools()
}, [refetchMcpTools])
const activeTabCollectionList = useMemo(() => {
return collectionList.filter((collection) => collection.type === activeTab)
}, [activeTab, collectionList])
@@ -250,10 +235,13 @@ const ProviderList = ({ category, contentInset = 'default', layout }: ProviderLi
)}
{activeTab === 'mcp' && (
<MCPList
providers={mcpToolsQuery.data ?? []}
isLoading={mcpToolsQuery.isLoading}
searchText={keywords}
contentInset={contentInset}
createdProviderId={createdMCPProviderId}
showCreateCard={shouldShowMCPCreateCard}
onRefresh={refreshMcpTools}
onCreatedProviderHandled={handleCreatedMCPProviderHandled}
/>
)}
@@ -0,0 +1,21 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Icon from '../card-icon'
describe('Plugin card icon', () => {
it('lazy-loads URL icons and hides a failed image', () => {
render(<Icon src="https://example.com/plugin-icon.png" />)
const image = screen.getByAltText('')
expect(image).toHaveAttribute('src', 'https://example.com/plugin-icon.png')
expect(image).toHaveAttribute('loading', 'lazy')
expect(image).toHaveAttribute('decoding', 'async')
expect(image).toHaveAttribute('width', '40')
expect(image).toHaveAttribute('height', '40')
fireEvent.error(image)
expect(image).toHaveStyle({ display: 'none' })
})
})
@@ -11,6 +11,17 @@ const iconSizeMap = {
medium: 'w-9 h-9',
large: 'w-10 h-10',
}
const iconPixelSizeMap = {
xs: 16,
tiny: 24,
small: 32,
medium: 36,
large: 40,
}
type IconSize = keyof typeof iconSizeMap
const Icon = ({
className,
src,
@@ -27,7 +38,7 @@ const Icon = ({
}
installed?: boolean
installFailed?: boolean
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large'
size?: IconSize
}) => {
const iconClassName =
'flex justify-center items-center gap-2 absolute bottom-[-4px] right-[-4px] w-[18px] h-[18px] rounded-full border-2 border-components-panel-bg'
@@ -51,16 +62,19 @@ const Icon = ({
}
return (
<div
className={cn(
'relative shrink-0 rounded-md bg-contain bg-center bg-no-repeat',
iconSizeMap[size],
className,
)}
style={{
backgroundImage: `url(${src})`,
}}
>
<div className={cn('relative shrink-0 rounded-md', iconSizeMap[size], className)}>
<img
alt=""
className="size-full rounded-md object-contain object-center"
decoding="async"
height={iconPixelSizeMap[size]}
loading="lazy"
src={src}
width={iconPixelSizeMap[size]}
onError={({ currentTarget }) => {
currentTarget.style.display = 'none'
}}
/>
{installed && (
<div className={cn(iconClassName, 'bg-state-success-solid')}>
<RiCheckLine className="size-3 text-text-primary-on-surface" />
@@ -0,0 +1,151 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
const getMarketplacePluginsByCollectionId = vi.hoisted(() => vi.fn())
const getMarketplaceCollectionsAndPlugins = vi.hoisted(() => vi.fn())
vi.mock('@/service/base', () => ({
postMarketplace: vi.fn(),
}))
vi.mock('../utils', () => ({
getFormattedPlugin: (plugin: unknown) => plugin,
getMarketplaceCollectionsAndPlugins,
getMarketplacePluginsByCollectionId,
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { Wrapper, queryClient }
}
describe('useMarketplacePluginsByCollectionId', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should show loading while the first collection request is pending', async () => {
getMarketplacePluginsByCollectionId.mockImplementation(() => new Promise(() => {}))
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId('__model-settings-pinned-models'),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(getMarketplacePluginsByCollectionId).toHaveBeenCalledTimes(1)
})
expect(result.current.isLoading).toBe(true)
})
it('should retain collection results while refreshing cached data', async () => {
let resolveRefresh: (() => void) | undefined
getMarketplacePluginsByCollectionId
.mockResolvedValueOnce([{ plugin_id: 'cached-plugin', type: 'plugin' }])
.mockImplementationOnce(
() =>
new Promise<{ plugin_id: string; type: string }[]>((resolve) => {
resolveRefresh = () => resolve([{ plugin_id: 'refreshed-plugin', type: 'plugin' }])
}),
)
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { Wrapper, queryClient } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId('__model-settings-pinned-models'),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.plugins).toEqual([{ plugin_id: 'cached-plugin', type: 'plugin' }])
})
act(() => {
void queryClient.invalidateQueries({ queryKey: ['marketplaceCollectionPlugins'] })
})
await waitFor(() => {
expect(getMarketplacePluginsByCollectionId).toHaveBeenCalledTimes(2)
})
expect(result.current.plugins).toEqual([{ plugin_id: 'cached-plugin', type: 'plugin' }])
expect(result.current.isLoading).toBe(false)
await act(async () => {
resolveRefresh?.()
})
await waitFor(() => {
expect(result.current.plugins).toEqual([{ plugin_id: 'refreshed-plugin', type: 'plugin' }])
})
})
})
describe('useMarketplaceCollectionsAndPlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should retain collection results while refreshing cached data', async () => {
let resolveRefresh: (() => void) | undefined
getMarketplaceCollectionsAndPlugins
.mockResolvedValueOnce({
marketplaceCollections: [{ id: 'cached-collection' }],
marketplaceCollectionPluginsMap: {},
})
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRefresh = () =>
resolve({
marketplaceCollections: [{ id: 'refreshed-collection' }],
marketplaceCollectionPluginsMap: {},
})
}),
)
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { Wrapper, queryClient } = createWrapper()
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), {
wrapper: Wrapper,
})
act(() => {
result.current.queryMarketplaceCollectionsAndPlugins()
})
await waitFor(() => {
expect(result.current.marketplaceCollections).toEqual([{ id: 'cached-collection' }])
})
act(() => {
void queryClient.invalidateQueries({ queryKey: ['marketplaceCollectionsAndPlugins'] })
})
await waitFor(() => {
expect(getMarketplaceCollectionsAndPlugins).toHaveBeenCalledTimes(2)
})
expect(result.current.marketplaceCollections).toEqual([{ id: 'cached-collection' }])
expect(result.current.isLoading).toBe(false)
await act(async () => {
resolveRefresh?.()
})
await waitFor(() => {
expect(result.current.marketplaceCollections).toEqual([{ id: 'refreshed-collection' }])
})
})
})
@@ -41,7 +41,7 @@ export const useMarketplaceCollectionsAndPlugins = () => {
},
[],
)
const isLoading = !!queryParams && (isFetching || isPending)
const isLoading = !!queryParams && (isPending || (isFetching && !data))
return {
marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections,
@@ -73,14 +73,14 @@ export const useMarketplacePluginsByCollectionId = (
return {
plugins: data || [],
isLoading: !!collectionId && (isFetching || isPending),
isLoading: !!collectionId && (isPending || (isFetching && !data)),
isSuccess,
}
}
/**
* @deprecated Use useMarketplacePlugins from query.ts instead
*/
export const useMarketplacePlugins = () => {
export const useMarketplacePlugins = (enabled = true) => {
const queryClient = useQueryClient()
const [queryParams, setQueryParams] = useState<PluginsSearchParams>()
@@ -150,7 +150,7 @@ export const useMarketplacePlugins = () => {
return loaded < (lastPage.total || 0) ? nextPage : undefined
},
initialPageParam: 1,
enabled: !!queryParams,
enabled: enabled && !!queryParams,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
retry: false,
@@ -187,6 +187,7 @@ export const useMarketplacePlugins = () => {
: undefined
const total = hasQuery && hasData ? marketplacePluginsQuery.data.pages?.[0]?.total : undefined
const isPluginsLoading =
enabled &&
hasQuery &&
(marketplacePluginsQuery.isPending ||
(marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data))
@@ -213,6 +213,10 @@ describe('PluginItem', () => {
// Assert
const img = screen.getByRole('img')
expect(img).toHaveAttribute('alt', `plugin-${plugin.plugin_unique_identifier}-logo`)
expect(img).toHaveAttribute('loading', 'lazy')
expect(img).toHaveAttribute('decoding', 'async')
expect(img).toHaveAttribute('width', '40')
expect(img).toHaveAttribute('height', '40')
})
it('should not render category label in corner mark', () => {
@@ -137,8 +137,12 @@ const PluginItem: FC<Props> = ({
<div className="flex size-10 items-center justify-center overflow-hidden rounded-xl border border-components-panel-border-subtle">
<img
className="size-full"
decoding="async"
height={40}
loading="lazy"
src={iconSrc}
alt={`plugin-${plugin_unique_identifier}-logo`}
width={40}
/>
</div>
<div className="ml-3 w-0 grow">
@@ -1,6 +1,6 @@
import type { PluginDetail } from '../../types'
import type { Collection } from '@/app/components/tools/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getStepByStepTourTargetSelector,
@@ -22,8 +22,12 @@ const mockSetFilters = vi.fn()
const mockSetCurrentPluginID = vi.fn()
const mockLoadNextPage = vi.fn()
const mockInvalidateInstalledPluginList = vi.fn()
const mockRemoveFilteredInstalledPluginPageOnUnmount = vi.fn()
const mockUseInstalledPluginList = vi.fn()
const mockPluginListWithLatestVersion = vi.fn<() => PluginDetail[]>(() => [])
const intersectionObserverCallbacks: IntersectionObserverCallback[] = []
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
vi.mock('@tanstack/react-query', () => ({
queryOptions: (options: unknown) => options,
@@ -34,8 +38,11 @@ vi.mock('@/i18n-config', () => ({
}))
vi.mock('@/service/use-plugins', () => ({
normalizePluginCategoryListLanguage: (locale: string) => locale,
useInstalledPluginList: (...args: unknown[]) => mockUseInstalledPluginList(...args),
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
useRemoveFilteredInstalledPluginPageOnUnmount: (...args: unknown[]) =>
mockRemoveFilteredInstalledPluginPageOnUnmount(...args),
}))
vi.mock('../../hooks', () => ({
@@ -160,7 +167,7 @@ vi.mock('@/app/components/integrations/tool-provider-card', () => ({
),
}))
vi.mock('@/app/components/integrations/hooks/use-tool-marketplace-panel', () => ({
vi.mock('@/app/components/tools/marketplace/use-tool-marketplace-panel', () => ({
useToolMarketplacePanel: () => ({
isMarketplaceArrowVisible: true,
marketplaceContext: {},
@@ -265,6 +272,18 @@ describe('PluginsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
intersectionObserverCallbacks.length = 0
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(callback: IntersectionObserverCallback) {
intersectionObserverCallbacks.push(callback)
}
observe = mockObserve
disconnect = mockDisconnect
},
)
mockState.filters = { categories: [], tags: [], searchQuery: '' }
mockState.currentPluginID = undefined
mockUseInstalledPluginList.mockReturnValue({
@@ -279,6 +298,7 @@ describe('PluginsPanel', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('renders the loading state while the plugin list is pending', () => {
@@ -351,23 +371,49 @@ describe('PluginsPanel', () => {
expect(screen.getByTestId('plugin-list')).not.toHaveTextContent('tool-plugin')
})
it('loads the scoped plugin category list whenever an integrations category panel mounts', () => {
render(<PluginsPanel contentInset="compact" fixedCategory={PluginCategoryEnum.trigger} />)
it.each([
PluginCategoryEnum.tool,
PluginCategoryEnum.trigger,
PluginCategoryEnum.agent,
PluginCategoryEnum.extension,
])('loads %s Integration Plugins in Studio-sized pages', (category) => {
render(<PluginsPanel contentInset="compact" fixedCategory={category} />)
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, {
category: PluginCategoryEnum.trigger,
refetchOnMount: 'always',
})
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(
expect.objectContaining({
category,
gcTime: 10 * 60 * 1000,
pageSize: 30,
staleTime: 5 * 60 * 1000,
}),
)
expect(mockUseInstalledPluginList.mock.calls.at(-1)?.[0]).not.toHaveProperty('refetchOnMount')
})
it('configures filtered cache cleanup for an Integration category panel', () => {
render(<PluginsPanel fixedCategory={PluginCategoryEnum.tool} />)
expect(mockRemoveFilteredInstalledPluginPageOnUnmount).toHaveBeenCalledWith(
PluginCategoryEnum.tool,
30,
expect.any(Object),
)
})
it('does not configure filtered cache cleanup for the standalone Plugin page', () => {
render(<PluginsPanel />)
expect(mockRemoveFilteredInstalledPluginPageOnUnmount).toHaveBeenCalledWith(
undefined,
30,
undefined,
)
})
it('loads the scoped tool plugin category list when fixed to tool plugins', () => {
render(<PluginsPanel contentInset="compact" fixedCategory={PluginCategoryEnum.tool} />)
expect(screen.getByTestId('filter-management')).toHaveAttribute('data-hide-tag-filter', 'false')
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, {
category: PluginCategoryEnum.tool,
refetchOnMount: 'always',
})
})
it('filters tool plugins, builtin tools, and marketplace suggestions by selected tags', () => {
@@ -711,6 +757,56 @@ describe('PluginsPanel', () => {
})
})
it.each([
PluginCategoryEnum.tool,
PluginCategoryEnum.trigger,
PluginCategoryEnum.agent,
PluginCategoryEnum.extension,
])('automatically loads more %s Plugins near the list end once per intersection', (category) => {
mockPluginListWithLatestVersion.mockReturnValue([
createPlugin('category-plugin', 'Category Plugin', [], category),
])
mockUseInstalledPluginList.mockReturnValue({
data: { plugins: [] },
isLoading: false,
isFetching: false,
isLastPage: false,
loadNextPage: mockLoadNextPage,
})
render(<PluginsPanel fixedCategory={category} />)
expect(intersectionObserverCallbacks).toHaveLength(1)
act(() => {
intersectionObserverCallbacks[0]?.(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
intersectionObserverCallbacks[0]?.(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(mockLoadNextPage).toHaveBeenCalledTimes(1)
})
it('does not observe the Tool Plugin list while the next page is loading', () => {
mockPluginListWithLatestVersion.mockReturnValue([createPlugin('tool-plugin', 'Tool Plugin')])
mockUseInstalledPluginList.mockReturnValue({
data: { plugins: [] },
isLoading: false,
isFetching: true,
isLastPage: false,
loadNextPage: mockLoadNextPage,
})
render(<PluginsPanel fixedCategory={PluginCategoryEnum.tool} />)
expect(intersectionObserverCallbacks).toHaveLength(0)
})
it('renders the empty state and keeps the current plugin detail in sync', () => {
mockState.currentPluginID = 'beta-tool'
mockState.filters.searchQuery = 'missing'
@@ -11,49 +11,15 @@ import {
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useToolMarketplacePanel } from '@/app/components/integrations/hooks/use-tool-marketplace-panel'
import IntegrationsToolProviderCard from '@/app/components/integrations/tool-provider-card'
import Marketplace from '@/app/components/tools/marketplace'
import { BuiltinMarketplacePanel } from '@/app/components/tools/marketplace/builtin-marketplace-panel'
import List from './list'
type BuiltinMarketplacePanelProps = {
containerRef: RefObject<HTMLDivElement | null>
contentInset: PluginPageContentInset
keywords: string
tagFilterValue: string[]
}
const BuiltinMarketplacePanel = ({
containerRef,
contentInset,
keywords,
tagFilterValue,
}: BuiltinMarketplacePanelProps) => {
const { isMarketplaceArrowVisible, marketplaceContext, showMarketplacePanel, toolListTailRef } =
useToolMarketplacePanel({
containerRef,
keywords,
tagFilterValue,
})
return (
<>
<div ref={toolListTailRef} />
<Marketplace
searchPluginText={keywords}
filterPluginTags={tagFilterValue}
isMarketplaceArrowVisible={isMarketplaceArrowVisible}
showMarketplacePanel={showMarketplacePanel}
marketplaceContext={marketplaceContext}
contentInset={contentInset}
/>
</>
)
}
type PluginsPanelResultsProps = {
autoLoadNextPage: boolean
canDeletePlugin: boolean
canUpdatePlugin: boolean
containerRef: RefObject<HTMLDivElement | null>
@@ -78,6 +44,7 @@ type PluginsPanelResultsProps = {
}
const PluginsPanelResults = ({
autoLoadNextPage,
canDeletePlugin,
canUpdatePlugin,
containerRef,
@@ -101,6 +68,42 @@ const PluginsPanelResults = ({
tagFilterValue,
}: PluginsPanelResultsProps) => {
const { t } = useTranslation()
const loadMoreAnchorRef = useRef<HTMLDivElement>(null)
const loadNextPageRequestedRef = useRef(false)
useEffect(() => {
const anchor = loadMoreAnchorRef.current
const root = containerRef.current
if (!isFetching) loadNextPageRequestedRef.current = false
if (
!autoLoadNextPage ||
!anchor ||
!root ||
isFetching ||
isLastPage ||
!globalThis.IntersectionObserver
)
return
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting || loadNextPageRequestedRef.current) return
loadNextPageRequestedRef.current = true
loadNextPage()
},
{
root,
rootMargin: '200px',
threshold: 0.1,
},
)
observer.observe(anchor)
return () => observer.disconnect()
}, [autoLoadNextPage, containerRef, isFetching, isLastPage, loadNextPage])
return (
<ScrollArea
@@ -149,11 +152,14 @@ const PluginsPanelResults = ({
<div className="flex w-full justify-center py-4">
{isFetching ? (
<Loading className="size-8" />
) : (
) : autoLoadNextPage ? null : (
<Button onClick={loadNextPage}>
{t(($) => $['common.loadMore'], { ns: 'workflow' })}
</Button>
)}
{autoLoadNextPage && (
<div ref={loadMoreAnchorRef} className="h-px w-full" aria-hidden />
)}
</div>
)}
{hasToolMarketplacePanel && (
@@ -15,7 +15,12 @@ import ProviderDetail from '@/app/components/tools/provider/detail'
import { useGetLanguage } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { renderI18nObject } from '@/i18n-config'
import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import {
normalizePluginCategoryListLanguage,
useInstalledPluginList,
useInvalidateInstalledPluginList,
useRemoveFilteredInstalledPluginPageOnUnmount,
} from '@/service/use-plugins'
import { usePluginsWithLatestVersion } from '../hooks'
import { PluginCategoryEnum } from '../types'
import { pluginPageContentFrameClassNames, pluginPageContentInsetClassNames } from './content-inset'
@@ -26,6 +31,10 @@ import PluginListSkeleton from './plugin-list-skeleton'
import PluginsPanelResults from './plugins-panel-results'
import { EMPTY_BUILTIN_TOOLS, filterBuiltinTools } from './plugins-panel-utils'
const INTEGRATION_PLUGIN_PAGE_SIZE = 30
const INTEGRATION_PLUGIN_STALE_TIME = 5 * 60 * 1000
const INTEGRATION_PLUGIN_GC_TIME = 10 * 60 * 1000
const matchesSearchQuery = (
plugin: PluginDetail & { latest_version: string },
query: string,
@@ -84,6 +93,17 @@ const PluginsPanel = ({
isAgentStrategyIntegrationPage ||
isExtensionIntegrationPage
const supportsTagFilter = !fixedCategory || isToolIntegrationPage || isTriggerIntegrationPage
const installedPluginFilters = useMemo(
() =>
isIntegrationCategoryPage
? {
language: normalizePluginCategoryListLanguage(locale),
query: filters.searchQuery,
tags: supportsTagFilter ? filters.tags : [],
}
: undefined,
[filters.searchQuery, filters.tags, isIntegrationCategoryPage, locale, supportsTagFilter],
)
const { data: enableMarketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: (s) => s.enable_marketplace,
@@ -94,18 +114,20 @@ const PluginsPanel = ({
isFetching,
isLastPage,
loadNextPage,
} = useInstalledPluginList(
false,
100,
fixedCategory
? {
category: fixedCategory,
refetchOnMount: isIntegrationCategoryPage ? 'always' : undefined,
}
: undefined,
)
} = useInstalledPluginList({
category: fixedCategory,
filters: installedPluginFilters,
gcTime: isIntegrationCategoryPage ? INTEGRATION_PLUGIN_GC_TIME : undefined,
pageSize: isIntegrationCategoryPage ? INTEGRATION_PLUGIN_PAGE_SIZE : 100,
staleTime: isIntegrationCategoryPage ? INTEGRATION_PLUGIN_STALE_TIME : undefined,
})
const pluginListWithLatestVersion = usePluginsWithLatestVersion(pluginList?.plugins)
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
useRemoveFilteredInstalledPluginPageOnUnmount(
isIntegrationCategoryPage ? fixedCategory : undefined,
INTEGRATION_PLUGIN_PAGE_SIZE,
installedPluginFilters,
)
const currentPluginID = usePluginPageContext((v) => v.currentPluginID)
const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID)
const [currentBuiltinToolID, setCurrentBuiltinToolID] = useState<string | undefined>()
@@ -236,6 +258,7 @@ const PluginsPanel = ({
<>
{hasVisiblePlugins || hasVisibleBuiltinTools || hasToolMarketplacePanel ? (
<PluginsPanelResults
autoLoadNextPage={isIntegrationCategoryPage}
containerRef={containerRef}
contentFrameClassName={contentFrameClassName}
contentInset={contentInset}
+1 -12
View File
@@ -2,7 +2,7 @@ import type { FormTypeEnum } from '../base/form/types'
import type { CredentialFormSchemaBase } from '../header/account-setting/model-provider-page/declarations'
import type { AutoUpdateConfig } from './reference-setting-modal/auto-update-setting/types'
import type { TypeWithI18N } from '@/app/components/base/form/types'
import type { Collection, ToolCredential } from '@/app/components/tools/types'
import type { ToolCredential } from '@/app/components/tools/types'
import type { Locale } from '@/i18n-config'
export enum PluginCategoryEnum {
@@ -441,17 +441,6 @@ export type MetaData = {
package: string
}
export type InstalledPluginListWithTotalResponse = {
plugins: PluginDetail[]
total: number
}
export type InstalledPluginCategoryListResponse = {
plugins: PluginDetail[]
builtin_tools: Collection[]
has_more: boolean
}
export type GitHubItemAndMarketPlaceDependency = {
type: 'github' | 'marketplace' | 'package'
value: {
@@ -7,6 +7,7 @@ import type {
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@/test/console/render'
@@ -980,10 +981,11 @@ describe('update-plugin', () => {
expect(onShowChange).not.toHaveBeenCalled()
})
it('should call onSelect with correct params when a version is selected', () => {
it('should call onSelect with correct params when a version is selected', async () => {
// Arrange
const onSelect = vi.fn()
const onShowChange = vi.fn()
const user = userEvent.setup()
// Act
render(
@@ -995,12 +997,7 @@ describe('update-plugin', () => {
onShowChange={onShowChange}
/>,
)
// Click on version 2.0.0
const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/)
const version2Element = versionElements.find((el) => el.textContent === '2.0.0')
if (version2Element) {
fireEvent.click(version2Element.closest('div[class*="cursor-pointer"]')!)
}
await user.click(screen.getByRole('button', { name: /2\.0\.0/ }))
// Assert
expect(onSelect).toHaveBeenCalledWith({
@@ -1011,9 +1008,10 @@ describe('update-plugin', () => {
expect(onShowChange).toHaveBeenCalledWith(false)
})
it('should not call onSelect when clicking on current version', () => {
it('should not call onSelect when clicking on current version', async () => {
// Arrange
const onSelect = vi.fn()
const user = userEvent.setup()
// Act
render(
@@ -1024,20 +1022,16 @@ describe('update-plugin', () => {
onSelect={onSelect}
/>,
)
// Click on current version 1.0.0
const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/)
const version1Element = versionElements.find((el) => el.textContent === '1.0.0')
if (version1Element) {
fireEvent.click(version1Element.closest('div[class*="cursor"]')!)
}
await user.click(screen.getByRole('button', { name: /1\.0\.0/ }))
// Assert
expect(onSelect).not.toHaveBeenCalled()
})
it('should indicate downgrade when selecting a lower version', () => {
it('should indicate downgrade when selecting a lower version', async () => {
// Arrange
const onSelect = vi.fn()
const user = userEvent.setup()
// Act
render(
@@ -1048,12 +1042,7 @@ describe('update-plugin', () => {
onSelect={onSelect}
/>,
)
// Click on version 1.0.0 (downgrade)
const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/)
const version1Element = versionElements.find((el) => el.textContent === '1.0.0')
if (version1Element) {
fireEvent.click(version1Element.closest('div[class*="cursor-pointer"]')!)
}
await user.click(screen.getByRole('button', { name: /1\.0\.0/ }))
// Assert
expect(onSelect).toHaveBeenCalledWith({

Some files were not shown because too many files have changed in this diff Show More