Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d790d634d | ||
|
|
2b343f25d4 | ||
|
|
45317a11b9 | ||
|
|
0e6af39553 | ||
|
|
b81d521e0b | ||
|
|
c487dc2d7c | ||
|
|
0ed9fb4db6 | ||
|
|
52b825a4a1 | ||
|
|
ffb1e29527 | ||
|
|
bedb3c571b | ||
|
|
05df469e28 | ||
|
|
07ab81c9f0 | ||
|
|
1319f485a1 | ||
|
|
b12ad644ee | ||
|
|
8073ba2fbe | ||
|
|
4919cc2395 | ||
|
|
e3dcf2efdb | ||
|
|
de7d4d4ab2 | ||
|
|
a1efdeeabb | ||
|
|
15cea0c08d | ||
|
|
543642542f | ||
|
|
0d71273e30 | ||
|
|
671742d5b6 | ||
|
|
e67d45cf7c | ||
|
|
5d5439a169 | ||
|
|
6506d06f61 | ||
|
|
689da4782f | ||
|
|
3efe5f0da8 | ||
|
|
3665f6cb73 | ||
|
|
39ffee49ff | ||
|
|
952d3482bb | ||
|
|
6fc6ac3d4c | ||
|
|
c1647cd2a6 | ||
|
|
9b257f7df2 | ||
|
|
aff527ce39 | ||
|
|
4ca9c36421 | ||
|
|
00e7f4d984 | ||
|
|
4375c7b988 |
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user