Compare commits

...
38 Commits
Author SHA1 Message Date
yyh 5d790d634d Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-27 15:16:43 +08:00
林玮 (Jade Lin) 2b343f25d4 Merge remote-tracking branch 'origin/linw/snp-581-celery-task-should-join-thread' into deploy/saas 2026-07-27 14:59:40 +08:00
林玮 (Jade Lin) 45317a11b9 fix: bound app worker cleanup wait 2026-07-27 14:54:04 +08:00
林玮 (Jade Lin) 0e6af39553 Merge remote-tracking branch 'origin/linw/snp-581-celery-task-should-join-thread' into deploy/saas 2026-07-27 13:57:41 +08:00
yyh b81d521e0b perf(web): prefetch workspace list on intent 2026-07-27 12:47:19 +08:00
yyh c487dc2d7c fix(web): refine workspace switcher loading state 2026-07-27 12:27:12 +08:00
autofix-ci[bot]andyyh 0ed9fb4db6 [autofix.ci] apply automated fixes 2026-07-27 11:56:34 +08:00
yyh 52b825a4a1 fix: align workspace card plan ownership 2026-07-27 11:56:33 +08:00
yyhyyhautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
ffb1e29527 refactor(workflow): remove hook barrel exports (#39588)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-27 11:42:40 +08:00
林玮 (Jade Lin) bedb3c571b fix: correct workflow task return type 2026-07-27 11:40:34 +08:00
林玮 (Jade Lin) 05df469e28 test: join workflow resume thread stub 2026-07-27 11:35:56 +08:00
林玮 (Jade Lin) 07ab81c9f0 test: join workflow generation thread stub 2026-07-27 11:35:23 +08:00
林玮 (Jade Lin) 1319f485a1 Merge remote-tracking branch 'origin/linw/snp-581-celery-task-should-join-thread' into deploy/saas 2026-07-27 11:09:17 +08:00
林玮 (Jade Lin) b12ad644ee Merge remote-tracking branch 'origin/linw/snp-560-webapp-site-response-with-mode' into deploy/saas 2026-07-27 10:47:28 +08:00
林玮 (Jade Lin) 8073ba2fbe refactor(web): remove redundant app mode propagation 2026-07-27 10:42:07 +08:00
autofix-ci[bot]andGitHub 4919cc2395 [autofix.ci] apply automated fixes 2026-07-27 02:03:29 +00:00
林玮 (Jade Lin) e3dcf2efdb Merge remote-tracking branch 'origin/linw/snp-560-webapp-site-response-with-mode' into deploy/saas 2026-07-27 09:43:03 +08:00
hjlarry de7d4d4ab2 Merge branch 'build/plugin-list-improve' into deploy/saas 2026-07-25 15:36:41 +08:00
hjlarry a1efdeeabb fix: simplify plugin list API contracts 2026-07-25 15:34:38 +08:00
autofix-ci[bot]andGitHub 15cea0c08d [autofix.ci] apply automated fixes 2026-07-25 07:10:01 +00:00
hjlarry 543642542f Merge remote-tracking branch 'myori/deploy/saas' into deploy/saas 2026-07-25 15:07:07 +08:00
hjlarry 0d71273e30 Merge branch 'build/plugin-list-improve' into deploy/saas 2026-07-25 14:56:43 +08:00
hjlarry 671742d5b6 chore: improve summary api 2026-07-25 11:04:22 +08:00
林玮 (Jade Lin) e67d45cf7c fix: wait for workflow worker cleanup 2026-07-24 20:24:30 +08:00
林玮 (Jade Lin) 5d5439a169 feat: expose app mode in webapp site response 2026-07-24 20:15:10 +08:00
林玮 (Jade Lin) 6506d06f61 Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-24 16:00:54 +08:00
林玮 (Jade Lin) 689da4782f Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-24 16:00:43 +08:00
林玮 (Jade Lin) 3efe5f0da8 refactor(web): use semantic labelling for explore banner slides
(cherry picked from commit 8e89bd38ae)
2026-07-24 15:48:27 +08:00
林玮 (Jade Lin) 3665f6cb73 refactor(web): add accessible label to plan range switcher
(cherry picked from commit 810d028a3c)
2026-07-24 15:48:27 +08:00
林玮 (Jade Lin) 39ffee49ff refactor(web): use semantic markup for workflow save status
(cherry picked from commit a689278288)
2026-07-24 15:48:27 +08:00
林玮 (Jade Lin) 952d3482bb refactor(web): use semantic markup for billing usage info cards
(cherry picked from commit 87b9873bb7)
2026-07-24 15:48:27 +08:00
autofix-ci[bot]andGitHub 6fc6ac3d4c [autofix.ci] apply automated fixes 2026-07-24 05:42:34 +00:00
hjlarry c1647cd2a6 feat: model plugin summary api 2026-07-24 13:38:35 +08:00
林玮 (Jade Lin) 9b257f7df2 Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-23 18:18:26 +08:00
autofix-ci[bot]andGitHub aff527ce39 [autofix.ci] apply automated fixes 2026-07-23 08:36:56 +00:00
hjlarry 4ca9c36421 Merge remote-tracking branch 'origin/build/plugin-list-improve' into deploy/saas 2026-07-23 16:36:16 +08:00
hjlarry 00e7f4d984 feat: add category-scoped installed plugin ids 2026-07-23 16:13:38 +08:00
hjlarry 4375c7b988 feat: plugin list query improve 2026-07-23 10:32:26 +08:00
30 changed files with 2043 additions and 234 deletions
@@ -26,7 +26,11 @@ 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
@@ -91,6 +95,11 @@ class ModelProviderListResponse(ResponseModel):
data: list[ProviderResponse]
class ModelProviderSummaryListResponse(ResponseModel):
data: list[ModelProviderSummaryResponse]
plugins: dict[str, ModelProviderPluginSummaryResponse]
class ProviderCredentialsResponse(ResponseModel):
credentials: dict[str, Any] | None = None
@@ -114,6 +123,7 @@ register_response_schema_models(
console_ns,
SimpleResultResponse,
ModelProviderListResponse,
ModelProviderSummaryListResponse,
ProviderCredentialsResponse,
ValidationResultResponse,
ModelProviderPaymentCheckoutUrlResponse,
@@ -140,6 +150,25 @@ 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/<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):
@@ -325,6 +338,10 @@ class PluginListResponse(ResponseModel):
total: int
class PluginInstalledIdsResponse(ResponseModel):
plugin_ids: list[str]
class PluginVersionsResponse(ResponseModel):
versions: Mapping[str, PluginService.LatestPluginCache | None]
@@ -384,6 +401,7 @@ register_schema_models(
console_ns,
ParserList,
PluginCategoryListQuery,
PluginInstalledIdsQuery,
PluginAutoUpgradeSettingsPayload,
PluginPermissionSettingsPayload,
ParserLatest,
@@ -420,6 +438,7 @@ register_response_schema_models(
PluginDebuggingKeyResponse,
PluginDynamicOptionsResponse,
PluginInstallationsResponse,
PluginInstalledIdsResponse,
PluginInstallTaskStartResponse,
PluginListResponse,
PluginManifestResponse,
@@ -477,7 +496,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)
@@ -498,6 +549,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)
@@ -552,7 +610,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)
@@ -560,13 +620,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,
@@ -578,6 +651,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__])
@@ -616,23 +616,34 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
message_snapshot = MessageSnapshot.from_message(message)
session.close()
# return response or stream generator
response = self._handle_advanced_chat_response(
application_generate_entity=application_generate_entity,
workflow=workflow_snapshot,
queue_manager=queue_manager,
conversation=conversation_snapshot,
message=message_snapshot,
user=user,
stream=stream,
draft_var_saver_factory=self._get_draft_var_saver_factory(
invoke_from,
account=user,
tenant_id=application_generate_entity.app_config.tenant_id,
),
)
try:
response = self._handle_advanced_chat_response(
application_generate_entity=application_generate_entity,
workflow=workflow_snapshot,
queue_manager=queue_manager,
conversation=conversation_snapshot,
message=message_snapshot,
user=user,
stream=stream,
draft_var_saver_factory=self._get_draft_var_saver_factory(
invoke_from,
account=user,
tenant_id=application_generate_entity.app_config.tenant_id,
),
)
converted_response = AdvancedChatAppGenerateResponseConverter.convert(
response=response,
invoke_from=invoke_from,
)
except BaseException:
self._join_worker_thread(worker_thread)
raise
return AdvancedChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
if isinstance(converted_response, Generator):
return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread)
self._join_worker_thread(worker_thread)
return converted_response
def _generate_worker(
self,
+29
View File
@@ -1,3 +1,5 @@
import logging
import threading
from collections.abc import Generator, Mapping, Sequence
from contextlib import AbstractContextManager, nullcontext
from typing import TYPE_CHECKING, Any, Union, final
@@ -23,6 +25,10 @@ from services.workflow_draft_variable_service import DraftVariableSaver as Draft
if TYPE_CHECKING:
from graphon.variables.input_entities import VariableEntity
logger = logging.getLogger(__name__)
_WORKER_THREAD_JOIN_TIMEOUT_SECONDS = 300
@final
class _DebuggerDraftVariableSaver:
@@ -64,6 +70,29 @@ class _DebuggerDraftVariableSaver:
class BaseAppGenerator:
_file_access_controller: DatabaseFileAccessController = DatabaseFileAccessController()
@staticmethod
def _join_worker_thread(worker_thread: threading.Thread) -> None:
# Bound the wait so a leaked app worker cannot occupy an execution slot indefinitely.
worker_thread.join(timeout=_WORKER_THREAD_JOIN_TIMEOUT_SECONDS)
if worker_thread.is_alive():
logger.warning(
"Possible app worker thread leak: thread_name=%s timeout_seconds=%s; "
"continuing without waiting further to avoid occupying an execution slot indefinitely",
worker_thread.name,
_WORKER_THREAD_JOIN_TIMEOUT_SECONDS,
)
@staticmethod
def _wrap_stream_with_worker_thread_join[ResponseT](
response_stream: Generator[ResponseT, None, None],
worker_thread: threading.Thread,
) -> Generator[ResponseT, None, None]:
"""Keep the producer owned by the response stream until both finish."""
try:
yield from response_stream
finally:
BaseAppGenerator._join_worker_thread(worker_thread)
@staticmethod
def _bind_file_access_scope(
*,
@@ -351,17 +351,28 @@ class PipelineGenerator(BaseAppGenerator):
user,
tenant_id=pipeline.tenant_id,
)
# return response or stream generator
response = self._handle_response(
application_generate_entity=application_generate_entity,
workflow=workflow,
queue_manager=queue_manager,
user=user,
stream=streaming,
draft_var_saver_factory=draft_var_saver_factory,
)
try:
response = self._handle_response(
application_generate_entity=application_generate_entity,
workflow=workflow,
queue_manager=queue_manager,
user=user,
stream=streaming,
draft_var_saver_factory=draft_var_saver_factory,
)
converted_response = WorkflowAppGenerateResponseConverter.convert(
response=response,
invoke_from=invoke_from,
)
except BaseException:
self._join_worker_thread(worker_thread)
raise
return WorkflowAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
if isinstance(converted_response, Generator):
return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread)
self._join_worker_thread(worker_thread)
return converted_response
def single_iteration_generate(
self,
+21 -10
View File
@@ -405,17 +405,28 @@ class WorkflowAppGenerator(BaseAppGenerator):
tenant_id=app_model.tenant_id,
)
# return response or stream generator
response = self._handle_response(
application_generate_entity=application_generate_entity,
workflow=workflow,
queue_manager=queue_manager,
user=user,
draft_var_saver_factory=draft_var_saver_factory,
stream=streaming,
)
try:
response = self._handle_response(
application_generate_entity=application_generate_entity,
workflow=workflow,
queue_manager=queue_manager,
user=user,
draft_var_saver_factory=draft_var_saver_factory,
stream=streaming,
)
converted_response = WorkflowAppGenerateResponseConverter.convert(
response=response,
invoke_from=invoke_from,
)
except BaseException:
self._join_worker_thread(worker_thread)
raise
return WorkflowAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
if isinstance(converted_response, Generator):
return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread)
self._join_worker_thread(worker_thread)
return converted_response
def single_iteration_generate(
self,
+17 -1
View File
@@ -12,7 +12,7 @@ from core.agent.plugin_entities import AgentProviderEntityWithPlugin
from core.datasource.entities.datasource_entities import DatasourceProviderEntityWithPlugin
from core.plugin.entities.base import BasePluginEntity
from core.plugin.entities.parameters import PluginParameterOption
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity, PluginInstallationSource
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin
from core.trigger.entities.entities import TriggerProviderEntity
@@ -94,6 +94,18 @@ 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
class PluginTextEmbeddingNumTokensResponse(BaseModel):
"""
Response for number of tokens.
@@ -207,6 +219,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,
@@ -47,6 +48,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,
PluginModelProviderEntity,
PluginVerification,
)
@@ -78,6 +79,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
@@ -265,7 +272,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
@@ -350,7 +357,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)
@@ -656,6 +663,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.
@@ -672,17 +699,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,
+91
View File
@@ -10567,6 +10567,13 @@ Update a plugin endpoint
| ---- | ----------- | ------ |
| 200 | Model providers retrieved successfully | **application/json**: [ModelProviderListResponse](#modelproviderlistresponse)<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
@@ -11112,6 +11119,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
@@ -11370,8 +11390,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
@@ -19307,6 +19330,16 @@ Enum class for model property key.
| ---- | ---- | ----------- | -------- |
| ModelPropertyKey | string | Enum class for model property key. | |
#### 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 |
| status | [CustomConfigurationStatus](#customconfigurationstatus) | | Yes |
#### ModelProviderListResponse
| Name | Type | Description | Required |
@@ -19319,6 +19352,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 |
@@ -20307,8 +20383,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
@@ -20509,6 +20588,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,64 @@ class SystemConfigurationResponse(BaseModel):
quota_configurations: list[QuotaConfiguration] = []
class ModelProviderCustomConfigurationSummaryResponse(BaseModel):
status: CustomConfigurationStatus
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.
+272 -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,242 @@ 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 _is_system_provider_enabled(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
system_enabled = self._is_system_provider_enabled(provider_name)
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,
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.
@@ -457,7 +457,7 @@ def _publish_streaming_response(
@shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE)
def workflow_based_app_execution_task(
payload: str,
) -> Generator[Mapping[str, Any] | str, None, None] | Mapping[str, Any] | None:
) -> Mapping[str, Any] | None:
exec_params = AppExecutionParams.model_validate_json(payload)
logger.info("workflow_based_app_execution_task run with params: %s", exec_params)
@@ -15,9 +15,11 @@ from controllers.console.workspace.model_providers import (
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,6 +29,10 @@ from models.provider import ProviderType
from services.entities.model_provider_entities import (
CustomConfigurationResponse,
CustomConfigurationStatus,
ModelProviderCustomConfigurationSummaryResponse,
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
ModelProviderSystemConfigurationSummaryResponse,
ProviderResponse,
SystemConfigurationResponse,
)
@@ -140,6 +146,80 @@ 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,
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",
"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 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()
@@ -730,6 +792,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()
@@ -634,6 +634,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/"
)
@@ -661,3 +666,112 @@ 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",
"status",
}
assert set(custom_configuration_properties) == {
"available_credentials",
"current_credential_id",
"current_credential_name",
"current_credential_usable",
"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",
}
@@ -442,6 +442,13 @@ class TestAdvancedChatAppGeneratorInternals:
def start(self):
thread_data["started"] = True
def join(self, timeout):
thread_data["joined"] = True
thread_data["join_timeout"] = timeout
def is_alive(self):
return False
monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread)
monkeypatch.setattr(
"core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session)
@@ -475,6 +482,8 @@ class TestAdvancedChatAppGeneratorInternals:
assert response["response"] == {"raw": True}
assert thread_data["started"] is True
assert thread_data["joined"] is True
assert thread_data["join_timeout"] == 300
assert "pause-layer" in thread_data["kwargs"]["graph_engine_layers"]
assert generator._dialogue_count == 3
assert init_records.call_args.kwargs["session"] is db_session
@@ -542,6 +551,13 @@ class TestAdvancedChatAppGeneratorInternals:
def start(self):
thread_data["started"] = True
def join(self, timeout):
thread_data["joined"] = True
thread_data["join_timeout"] = timeout
def is_alive(self):
return False
monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread)
monkeypatch.setattr(
"core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session)
@@ -574,6 +590,8 @@ class TestAdvancedChatAppGeneratorInternals:
init_records.assert_not_called()
get_thread_messages_length.assert_called_once_with(conversation.id, session=db_session)
assert thread_data["started"] is True
assert thread_data["joined"] is True
assert thread_data["join_timeout"] == 300
db_session.commit.assert_not_called()
db_session.refresh.assert_not_called()
db_session.close.assert_called_once()
@@ -435,6 +435,7 @@ def test_generate_success_returns_converted(generator, mocker: MockerFixture):
mocker.patch.object(module, "PipelineQueueManager", return_value=queue_manager)
worker_thread = MagicMock()
worker_thread.is_alive.return_value = False
mocker.patch.object(module.threading, "Thread", return_value=worker_thread)
mocker.patch.object(generator, "_get_draft_var_saver_factory", return_value=MagicMock())
@@ -461,6 +462,7 @@ def test_generate_success_returns_converted(generator, mocker: MockerFixture):
)
assert result == "converted"
worker_thread.join.assert_called_once_with(timeout=300)
def test_single_iteration_generate_validates_inputs(generator, mocker: MockerFixture):
@@ -1,3 +1,6 @@
import logging
from unittest.mock import Mock
import pytest
from core.app.apps.base_app_generator import BaseAppGenerator
@@ -369,6 +372,58 @@ def test_validate_inputs_optional_file_with_empty_string_ignores_default():
class TestBaseAppGeneratorExtras:
def test_wrap_stream_joins_worker_after_stream_exhaustion(self):
base_app_generator = BaseAppGenerator()
worker_thread = Mock()
worker_thread.is_alive.return_value = False
def response_stream():
yield {"event": "workflow_finished"}
managed_stream = base_app_generator._wrap_stream_with_worker_thread_join(
response_stream(),
worker_thread,
)
assert next(managed_stream) == {"event": "workflow_finished"}
worker_thread.join.assert_not_called()
with pytest.raises(StopIteration):
next(managed_stream)
worker_thread.join.assert_called_once_with(timeout=300)
def test_wrap_stream_joins_worker_when_stream_closes(self):
base_app_generator = BaseAppGenerator()
worker_thread = Mock()
worker_thread.is_alive.return_value = False
def response_stream():
yield {"event": "workflow_started"}
yield {"event": "workflow_finished"}
managed_stream = base_app_generator._wrap_stream_with_worker_thread_join(
response_stream(),
worker_thread,
)
assert next(managed_stream) == {"event": "workflow_started"}
managed_stream.close()
worker_thread.join.assert_called_once_with(timeout=300)
def test_join_worker_thread_warns_when_thread_remains_alive(self, caplog: pytest.LogCaptureFixture):
worker_thread = Mock()
worker_thread.name = "leaked-app-worker"
worker_thread.is_alive.return_value = True
with caplog.at_level(logging.WARNING, logger="core.app.apps.base_app_generator"):
BaseAppGenerator._join_worker_thread(worker_thread)
worker_thread.join.assert_called_once_with(timeout=300)
assert "Possible app worker thread leak" in caplog.text
assert "leaked-app-worker" in caplog.text
def test_prepare_user_inputs_converts_files_and_lists(self, monkeypatch: pytest.MonkeyPatch):
base_app_generator = BaseAppGenerator()
@@ -211,6 +211,13 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
def start(self):
return None
def join(self, timeout):
worker_kwargs["joined"] = True
worker_kwargs["join_timeout"] = timeout
def is_alive(self):
return False
mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", DummyThread)
app_model = SimpleNamespace(mode="workflow", tenant_id="tenant")
@@ -244,6 +251,8 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
assert result == "converted"
assert worker_kwargs["kwargs"]["graph_engine_layers"] == ("base-layer", pause_layer)
assert worker_kwargs["kwargs"]["graph_runtime_state"] is graph_runtime_state
assert worker_kwargs["joined"] is True
assert worker_kwargs["join_timeout"] == 300
assert draft_saver_factory.call_args.kwargs["tenant_id"] == app_model.tenant_id
@@ -286,6 +295,8 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
mocker.patch("core.app.apps.workflow.app_generator.WorkflowAppRunner", side_effect=runner_ctor)
worker_lifecycle: dict[str, bool] = {}
class ImmediateThread:
def __init__(self, target, kwargs):
target(**kwargs)
@@ -293,6 +304,13 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
def start(self):
return None
def join(self, timeout):
worker_lifecycle["joined"] = True
worker_lifecycle["join_timeout"] = timeout
def is_alive(self):
return False
mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", ImmediateThread)
mocker.patch(
@@ -331,5 +349,7 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
)
assert result == "raw-response"
assert worker_lifecycle["joined"] is True
assert worker_lifecycle["join_timeout"] == 300
runner_instance.run.assert_called_once()
queue_manager.graph_runtime_state = runtime_state
@@ -1,5 +1,9 @@
import threading
from collections.abc import Generator
import pytest
from core.app.apps.base_app_generator import BaseAppGenerator
from core.app.apps.workflow.active_workflow_tasks import (
active_workflow_task,
get_active_workflow_task_count,
@@ -28,3 +32,51 @@ def test_active_workflow_task_rejects_duplicate_task_id() -> None:
with pytest.raises(ValueError, match="already active"):
with active_workflow_task("task-a"):
pass
def test_managed_stream_waits_for_active_worker_cleanup() -> None:
worker_started = threading.Event()
release_worker = threading.Event()
stream_exhausted = threading.Event()
consumer_finished = threading.Event()
consumer_errors: list[BaseException] = []
def run_worker() -> None:
with active_workflow_task("task-a"):
worker_started.set()
release_worker.wait()
def response_stream() -> Generator[dict[str, str], None, None]:
yield {"event": "workflow_finished"}
stream_exhausted.set()
worker_thread = threading.Thread(target=run_worker)
worker_thread.start()
assert worker_started.wait(timeout=2)
managed_stream = BaseAppGenerator._wrap_stream_with_worker_thread_join(response_stream(), worker_thread)
assert next(managed_stream) == {"event": "workflow_finished"}
def finish_stream() -> None:
try:
list(managed_stream)
except BaseException as exc:
consumer_errors.append(exc)
finally:
consumer_finished.set()
consumer_thread = threading.Thread(target=finish_stream)
consumer_thread.start()
try:
assert stream_exhausted.wait(timeout=2)
assert not consumer_finished.is_set()
assert get_active_workflow_task_count() == 1
finally:
release_worker.set()
consumer_thread.join(timeout=2)
worker_thread.join(timeout=2)
assert not consumer_thread.is_alive()
assert not worker_thread.is_alive()
assert consumer_errors == []
assert get_active_workflow_task_count() == 0
@@ -15,6 +15,70 @@ from models.model import AppMode
class TestWorkflowAppGeneratorValidation:
def test_generate_stream_joins_worker_after_response_exhaustion(self, monkeypatch: pytest.MonkeyPatch):
generator = WorkflowAppGenerator()
worker_thread = Mock()
worker_thread.is_alive.return_value = False
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant",
app_id="app",
app_mode=AppMode.WORKFLOW,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id="workflow-id",
)
application_generate_entity = WorkflowAppGenerateEntity.model_construct(
task_id="task",
app_config=app_config,
inputs={},
files=[],
user_id="user",
stream=True,
invoke_from=InvokeFrom.WEB_APP,
extras={},
)
def response_stream():
yield {"event": "workflow_finished"}
monkeypatch.setattr(generator, "_bind_file_access_scope", lambda **kwargs: contextlib.nullcontext())
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.WorkflowAppQueueManager",
lambda **kwargs: SimpleNamespace(**kwargs),
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.current_app",
SimpleNamespace(_get_current_object=lambda: SimpleNamespace(name="flask")),
)
monkeypatch.setattr("core.app.apps.workflow.app_generator.contextvars.copy_context", lambda: "ctx")
monkeypatch.setattr("core.app.apps.workflow.app_generator.threading.Thread", lambda **kwargs: worker_thread)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.db",
SimpleNamespace(session=SimpleNamespace(close=Mock())),
)
monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory")
monkeypatch.setattr(generator, "_handle_response", lambda **kwargs: response_stream())
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert",
lambda response, invoke_from: response,
)
managed_stream = generator._generate(
app_model=SimpleNamespace(mode=AppMode.WORKFLOW, tenant_id="tenant"),
workflow=SimpleNamespace(id="workflow-id"),
user=SimpleNamespace(id="user"),
application_generate_entity=application_generate_entity,
invoke_from=InvokeFrom.WEB_APP,
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
streaming=True,
)
worker_thread.start.assert_called_once_with()
worker_thread.join.assert_not_called()
assert list(managed_stream) == [{"event": "workflow_finished"}]
worker_thread.join.assert_called_once_with(timeout=300)
def test_ensure_snippet_start_node_returns_original_for_non_snippet_workflow(self):
workflow = SimpleNamespace(kind_or_standard="workflow")
session = SimpleNamespace(scalar=Mock())
@@ -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
@@ -806,7 +806,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 (
@@ -850,7 +948,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)
@@ -917,14 +1021,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,15 @@ 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 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]:
@@ -96,6 +99,247 @@ 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",
)
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=False,
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, "_is_system_provider_enabled", MagicMock(return_value=False))
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.current_credential_name == "Production"
assert providers[0].custom_configuration.current_credential_usable is True
assert providers[0].system_configuration.enabled is False
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_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",
)
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",
)
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, "_is_system_provider_enabled", MagicMock(return_value=False))
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.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()
File diff suppressed because it is too large Load Diff
@@ -227,6 +227,13 @@ export type ModelProviderListResponse = {
data: Array<ProviderResponse>
}
export type ModelProviderSummaryListResponse = {
data: Array<ModelProviderSummaryResponse>
plugins: {
[key: string]: ModelProviderPluginSummaryResponse
}
}
export type ModelProviderPaymentCheckoutUrlResponse = {
payment_link: string
}
@@ -417,6 +424,10 @@ export type ParserPluginIdentifiers = {
plugin_unique_identifiers: Array<string>
}
export type PluginInstalledIdsResponse = {
plugin_ids: Array<string>
}
export type PluginListResponse = {
plugins: Array<PluginEntity>
total: number
@@ -1191,6 +1202,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 = {
@@ -1729,6 +1764,20 @@ 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
status: CustomConfigurationStatus
}
export type ModelProviderSystemConfigurationSummaryResponse = {
enabled: boolean
}
export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote'
export type ModelFeature =
| 'agent-thought'
| 'audio'
@@ -1899,8 +1948,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
@@ -3028,6 +3075,20 @@ export type GetWorkspacesCurrentModelProvidersResponses = {
export type GetWorkspacesCurrentModelProvidersResponse =
GetWorkspacesCurrentModelProvidersResponses[keyof GetWorkspacesCurrentModelProvidersResponses]
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: {
@@ -3574,6 +3635,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
@@ -3887,8 +3964,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'
}
@@ -283,6 +283,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
*/
@@ -1612,6 +1619,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
*
@@ -1802,6 +1833,45 @@ 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(),
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
*/
@@ -1947,11 +2017,6 @@ export const zPluginTaskResponse = z.object({
task: zPluginInstallTask,
})
/**
* PluginInstallationSource
*/
export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote'])
/**
* PluginBundleDependencyType
*/
@@ -3695,6 +3760,11 @@ export const zGetWorkspacesCurrentModelProvidersQuery = z.object({
*/
export const zGetWorkspacesCurrentModelProvidersResponse = zModelProviderListResponse
/**
* Model provider summaries retrieved successfully
*/
export const zGetWorkspacesCurrentModelProvidersSummaryResponse = zModelProviderSummaryListResponse
export const zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlPath = z.object({
provider: z.string(),
})
@@ -4070,6 +4140,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),
@@ -4240,8 +4319,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(),
})
/**