diff --git a/api/.env.example b/api/.env.example index 82ac26c5fc1..f6ad2db9fdc 100644 --- a/api/.env.example +++ b/api/.env.example @@ -54,6 +54,9 @@ ACCESS_TOKEN_EXPIRE_MINUTES=60 # Refresh token expiration time in days REFRESH_TOKEN_EXPIRE_DAYS=30 +# Lifetime in days of difyctl (OAuth device flow) tokens, 1-365; out-of-range values are clamped +# OAUTH_TTL_DAYS=14 + # redis configuration REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index ada427ac89f..796fb3bda7e 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1,6 +1,7 @@ +import logging from datetime import datetime, timedelta from enum import StrEnum -from typing import Literal +from typing import Final, Literal from pydantic import ( AliasChoices, @@ -18,6 +19,12 @@ from pydantic_settings import BaseSettings from .hosted_service import HostedServiceConfig +logger = logging.getLogger(__name__) + +DEFAULT_OAUTH_TTL_DAYS: Final = 14 +MIN_OAUTH_TTL_DAYS: Final = 1 +MAX_OAUTH_TTL_DAYS: Final = 365 + class SecurityConfig(BaseSettings): """ @@ -1100,6 +1107,35 @@ class AuthConfig(BaseSettings): default=True, ) + OAUTH_TTL_DAYS: int = Field( + description="Lifetime in days of the OAuth bearer tokens the device flow issues (difyctl login). " + f"Values outside {MIN_OAUTH_TTL_DAYS}-{MAX_OAUTH_TTL_DAYS} are clamped; a non-integer falls back " + "to the default.", + ge=MIN_OAUTH_TTL_DAYS, + le=MAX_OAUTH_TTL_DAYS, + default=DEFAULT_OAUTH_TTL_DAYS, + ) + + @field_validator("OAUTH_TTL_DAYS", mode="before") + @classmethod + def _clamp_oauth_ttl_days(cls, v: object) -> int: + # Clamp and warn instead of failing startup, so deployments with a bad value keep booting. + try: + value = int(str(v)) + except ValueError: + logger.warning("OAUTH_TTL_DAYS=%r is not an int; falling back to %d", v, DEFAULT_OAUTH_TTL_DAYS) + return DEFAULT_OAUTH_TTL_DAYS + clamped = min(max(value, MIN_OAUTH_TTL_DAYS), MAX_OAUTH_TTL_DAYS) + if clamped != value: + logger.warning( + "OAUTH_TTL_DAYS=%d outside %d-%d; clamping to %d", + value, + MIN_OAUTH_TTL_DAYS, + MAX_OAUTH_TTL_DAYS, + clamped, + ) + return clamped + OPENAPI_RATE_LIMIT_PER_TOKEN: NonNegativeInt = Field( description="Per-token rate limit on /openapi/v1/* (requests per minute). " "Bucket keyed on sha256(token), shared across api replicas via Redis. " diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index a35922b5982..7c5dd3db152 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -8,7 +8,7 @@ from werkzeug.exceptions import Forbidden from controllers.common.rbac import PlainApp, RBACCheck, Workspace from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.app.error import AppNotFoundError +from controllers.console.app.error import AppNotFoundError, DraftWorkflowNotSync from controllers.console.explore.error import RecommendedAppNotFoundError as RecommendedAppNotFoundHttpError from controllers.console.flask_admission import console_account_admission from controllers.console.wraps import RBACPermission, validate_request @@ -25,6 +25,7 @@ from services.agent.errors import ( ) from services.app.console_service import ConsoleAppNotFoundError from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus +from services.errors.app import DraftChangedError from services.errors.base import NoPermissionError from services.recommended_app_query_service import RecommendedAppNotFoundError @@ -115,6 +116,7 @@ class AppImportApi(Resource): class AppImportConfirmApi(Resource): @console_ns.response(HTTPStatus.OK, "Import confirmed", console_ns.models[Import.__name__]) @console_ns.response(HTTPStatus.BAD_REQUEST, "Import failed", console_ns.models[Import.__name__]) + @console_ns.response(HTTPStatus.CONFLICT, "The draft changed after the export the import is based on") @console_account_admission( allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())], @@ -124,6 +126,8 @@ class AppImportConfirmApi(Resource): result = application_services().apps.console.confirm_import(context, import_id) except NoPermissionError as exc: raise Forbidden(str(exc)) from exc + except DraftChangedError as exc: + raise DraftWorkflowNotSync() from exc status_code = HTTPStatus.BAD_REQUEST if result.status == ImportStatus.FAILED else HTTPStatus.OK return result.model_dump(mode="json"), status_code diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 733f75e4417..156d5be5f4d 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -103,10 +103,7 @@ from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, from services.errors.llm import InvokeRateLimitError from services.workflow_ref_service import WorkflowRefService from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService -from services.workflow_variable_reference_validator import ( - format_variable_reference_errors, - validate_variable_references, -) +from services.workflow_variable_reference_validator import advisory_variable_reference_warning logger = logging.getLogger(__name__) @@ -1295,21 +1292,6 @@ class DraftWorkflowNodeRunApi(Resource): ).model_dump(mode="json") -def _advisory_variable_reference_warning(graph_text: str | None) -> str | None: - """Return a non-blocking publish warning. A checker failure must not fail publish.""" - if not graph_text: - return None - try: - graph = json.loads(graph_text) - if not isinstance(graph, dict): - return None - issues = validate_variable_references(graph) - return format_variable_reference_errors(issues) if issues else None - except Exception: - logger.warning("Skipped advisory variable reference check", exc_info=True) - return None - - @console_ns.route("/apps//workflows/publish") class PublishedWorkflowApi(Resource): @console_ns.doc("get_published_workflow") @@ -1376,7 +1358,7 @@ class PublishedWorkflowApi(Resource): workflow_created_at = TimestampField().format(workflow.created_at) graph_text = workflow.graph - warning = _advisory_variable_reference_warning(graph_text) + warning = advisory_variable_reference_warning(graph_text) payload: dict[str, object] = { "result": "success", "created_at": workflow_created_at, diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 01d7853cbbf..6b245b1aa52 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -49,7 +49,14 @@ from controllers.common.schema import register_enum_models, register_response_sc from controllers.openapi._models import ( AccountPayload, AccountResponse, + AdvancedChatNodeRunPayload, AdvancedChatRunPayload, + AdvancedChatWebApp, + AdvancedChatWebAppPatch, + AgentAppInfo, + AgentAppInfoPatch, + AgentServiceApi, + AgentWebApp, AppDescribeInfo, AppDescribeQuery, AppDescribeResponse, @@ -58,11 +65,24 @@ from controllers.openapi._models import ( AppDslImportPayload, AppDslImportResponse, AppInfo, + AppInfoPatch, AppListQuery, AppListResponse, AppListRow, + AppSettingsInfo, + ChatAppInfo, + ChatAppInfoPatch, ChatRunPayload, + ChatWebApp, + ChatWebAppPatch, + CheckDependenciesResponse, CompletionRunPayload, + CreateAppPayload, + CreatedAppResponse, + CredentialFormField, + CredentialRef, + CredentialWriteResponse, + CustomModelRow, DeviceCodeRequest, DeviceCodeResponse, DeviceFlowErrorResponse, @@ -72,11 +92,25 @@ from controllers.openapi._models import ( DeviceMutateResponse, DevicePollRequest, DeviceTokenResponse, + DraftWorkflowRunPayload, + DslCheckPayload, + DslCheckResponse, + DslIssueRow, + EnvVariableListResponse, + EnvVariableSetPayload, FileUploadPayload, FormSubmitResponse, HealthResponse, Hint, HumanInputFormDefinitionResponse, + KnowledgeBaseListQuery, + KnowledgeBaseListResponse, + KnowledgeBaseRow, + KnowledgeRetrievalFragment, + LlmModelBlock, + MarketplacePluginListResponse, + MarketplacePluginQuery, + MarketplacePluginRow, MemberActionResponse, MemberInvitePayload, MemberInviteResponse, @@ -85,18 +119,65 @@ from controllers.openapi._models import ( MemberResponse, MemberRoleUpdatePayload, MessageMetadata, + ModelCredentialCreatePayload, + ModelCredentialUpdatePayload, + ModelListQuery, + ModelListResponse, + ModelProviderDetailResponse, + ModelProviderRow, + ModelRef, + ModelRow, + MultipleRetrievalFragment, + NodeRunPayload, + NodeTypeDetailResponse, + NodeTypeListResponse, + NodeTypeRow, OpenApiFormSubmitPayload, PermittedExternalAppsListQuery, PermittedExternalAppsListResponse, + PluginDeleteResponse, + PluginInstallPayload, + PluginListQuery, + PluginListResponse, + PluginProvides, + PluginRow, + PluginTaskItem, + PluginTaskResponse, + PluginTaskStartResponse, + ProviderCredentialCreatePayload, + ProviderCredentialUpdatePayload, + PublishPayload, + PublishResponse, + ReleaseCheckResponse, + RestoreResponse, RevokeResponse, + RunListQuery, + RunListResponse, ServerVersionResponse, + ServiceApi, + ServiceApiPatch, SessionListQuery, SessionListResponse, SessionRow, TaskStopResponse, + ToolCredentialCreatePayload, + ToolCredentialUpdatePayload, + ToolListQuery, + ToolListResponse, + ToolProviderDetailResponse, + ToolProviderRow, UsageInfo, + VersionListQuery, + VersionListResponse, + WebApp, + WebAppAccess, + WebAppAccessPayload, + WebAppPatch, + WebAppToken, WorkflowRunData, WorkflowRunPayload, + WorkflowWebApp, + WorkflowWebAppPatch, WorkspaceDetailResponse, WorkspaceListQuery, WorkspaceListResponse, @@ -104,28 +185,64 @@ from controllers.openapi._models import ( WorkspaceSummaryResponse, ) from fields.file_fields import FileResponse +from fields.workflow_run_fields import ( + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunNodeExecutionResponse, +) from services.entities.dsl_entities import CheckDependenciesResult, Import register_schema_models( openapi_ns, + AdvancedChatNodeRunPayload, AdvancedChatRunPayload, + AgentAppInfoPatch, AppDescribeQuery, + AppInfoPatch, + ChatAppInfoPatch, + ServiceApiPatch, + AdvancedChatWebAppPatch, + ChatWebAppPatch, + WebAppAccessPayload, + WebAppPatch, + WorkflowWebAppPatch, AppDslImportPayload, + DslCheckPayload, AppDslExportQuery, AppListQuery, ChatRunPayload, CompletionRunPayload, + CreateAppPayload, DeviceCodeRequest, DevicePollRequest, DeviceLookupQuery, DeviceMutateRequest, + DraftWorkflowRunPayload, FileUploadPayload, MemberInvitePayload, MemberListQuery, MemberRoleUpdatePayload, + NodeRunPayload, OpenApiFormSubmitPayload, + EnvVariableSetPayload, + MarketplacePluginQuery, PermittedExternalAppsListQuery, + PluginInstallPayload, + PluginListQuery, + ModelListQuery, + KnowledgeBaseListQuery, + ToolListQuery, + ProviderCredentialCreatePayload, + ProviderCredentialUpdatePayload, + ModelRef, + ModelCredentialCreatePayload, + ModelCredentialUpdatePayload, + ToolCredentialCreatePayload, + ToolCredentialUpdatePayload, + PublishPayload, + RunListQuery, SessionListQuery, + VersionListQuery, WorkflowRunPayload, WorkspaceListQuery, ) @@ -145,7 +262,11 @@ register_response_schema_models( AppDslExportResponse, Import, AppDslImportResponse, + DslIssueRow, + DslCheckResponse, + ReleaseCheckResponse, CheckDependenciesResult, + CheckDependenciesResponse, WorkflowRunData, AccountPayload, WorkspacePayload, @@ -172,6 +293,55 @@ register_response_schema_models( FileResponse, ServerVersionResponse, HealthResponse, + RunListResponse, + PublishResponse, + VersionListResponse, + RestoreResponse, + EnvVariableListResponse, + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunNodeExecutionResponse, + CreatedAppResponse, + NodeTypeRow, + NodeTypeListResponse, + NodeTypeDetailResponse, + AppSettingsInfo, + ChatAppInfo, + AgentAppInfo, + ServiceApi, + AgentServiceApi, + AdvancedChatWebApp, + AgentWebApp, + ChatWebApp, + WebApp, + WebAppAccess, + WebAppToken, + WorkflowWebApp, + MarketplacePluginRow, + MarketplacePluginListResponse, + PluginProvides, + PluginRow, + PluginListResponse, + PluginTaskStartResponse, + PluginTaskItem, + PluginTaskResponse, + PluginDeleteResponse, + CredentialFormField, + CredentialRef, + CredentialWriteResponse, + CustomModelRow, + ModelListResponse, + ToolListResponse, + ModelProviderDetailResponse, + ModelProviderRow, + ModelRow, + KnowledgeBaseListResponse, + KnowledgeBaseRow, + KnowledgeRetrievalFragment, + LlmModelBlock, + MultipleRetrievalFragment, + ToolProviderDetailResponse, + ToolProviderRow, ) # Standalone definition for contract codegen; ErrorBody.code stays an open # string on the wire so old clients keep parsing future codes. @@ -179,15 +349,25 @@ register_enum_models(openapi_ns, OpenApiErrorCode) from . import ( account, + app_create, app_dsl, + app_info, + app_release, app_run, + app_webapp, + app_workflow, apps, apps_permitted_external, files, human_input_form, index, + knowledge_bases, + model_providers, + node_types, oauth_device, oauth_device_sso, + plugins, + tool_providers, workflow_events, workspaces, ) @@ -196,15 +376,25 @@ from . import ( __all__ = [ "account", + "app_create", "app_dsl", + "app_info", + "app_release", "app_run", + "app_webapp", + "app_workflow", "apps", "apps_permitted_external", "files", "human_input_form", "index", + "knowledge_bases", + "model_providers", + "node_types", "oauth_device", "oauth_device_sso", + "plugins", + "tool_providers", "workflow_events", "workspaces", ] diff --git a/api/controllers/openapi/_app_settings.py b/api/controllers/openapi/_app_settings.py new file mode 100644 index 00000000000..1278b0d6a64 --- /dev/null +++ b/api/controllers/openapi/_app_settings.py @@ -0,0 +1,225 @@ +"""Reads and writes shared by the per-mode app-settings routes. Agent apps get their own +builders: their cards add the bound agent's role and whether a published version can serve.""" + +from __future__ import annotations + +from dataclasses import asdict +from enum import StrEnum +from typing import Any, Final, cast + +from flask import request +from pydantic import BaseModel +from werkzeug.exceptions import NotFound + +from configs import dify_config +from controllers.openapi._errors import ( + WebAppAccessModeConsoleOnly, + WebAppAccessUnavailable, + WebAppPublicAccessDisabled, +) +from controllers.openapi._models import ( + AgentAppInfo, + AgentAppInfoPatch, + AgentServiceApi, + AgentWebApp, + WebAppAccess, + WebAppAccessPayload, + WebAppToken, +) +from controllers.openapi.auth.context import Context +from enums import WebAppAccessMode +from extensions.ext_application_services import application_services +from models import AppMode +from services.app_site_service import AppSiteAppNotFoundError, AppSiteChanges, AppSiteNotFoundError +from services.enterprise.enterprise_service import EnterpriseService +from services.entities.app_entities import AppFieldChanges, AppRecord +from services.webapp_access_adapters import EnterpriseWebAppAccessPolicyGateway +from services.webapp_access_query_service import WebAppAccessUnavailableError + +REGULAR_MODES: Final = ( + AppMode.WORKFLOW, + AppMode.ADVANCED_CHAT, + AppMode.CHAT, + AppMode.AGENT_CHAT, + AppMode.COMPLETION, +) + +_SETTABLE_ACCESS_MODES: Final = frozenset( + {WebAppAccessMode.PUBLIC, WebAppAccessMode.PRIVATE_ALL, WebAppAccessMode.SSO_VERIFIED} +) +_ACCESS_POLICY: Final = EnterpriseWebAppAccessPolicyGateway(webapp_auth=EnterpriseService.WebAppAuth) + + +class WebAppPath(StrEnum): + """The web app's URL segment and the app modes served under it, as the console builds the share link.""" + + modes: frozenset[AppMode] + + CHAT = "chat", (AppMode.CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT_CHAT) + COMPLETION = "completion", (AppMode.COMPLETION,) + WORKFLOW = "workflow", (AppMode.WORKFLOW,) + AGENT = "agent", (AppMode.AGENT,) + + def __new__(cls, segment: str, modes: tuple[AppMode, ...]) -> WebAppPath: + member = str.__new__(cls, segment) + member._value_ = segment + member.modes = frozenset(modes) + return member + + @classmethod + def of(cls, mode: str) -> WebAppPath: + for path in cls: + if mode in path.modes: + return path + raise ValueError(f"No web app path for app mode {mode!r}") + + def url(self, base_url: str, code: str | None) -> str | None: + return f"{base_url}/{self}/{code}" if code else None + + +def app_base_url() -> str: + return dify_config.APP_WEB_URL or request.url_root.rstrip("/") + + +def app_info[T: BaseModel](ctx: Context, model: type[T]) -> T: + return model.model_validate(asdict(application_services().apps.console.get(ctx.request_context, ctx.app.id))) + + +def update_app_info[T: BaseModel](ctx: Context, patch: BaseModel, model: type[T]) -> T: + return model.model_validate(asdict(_update_app_info(ctx, patch.model_dump(exclude_unset=True, exclude_none=True)))) + + +def _stored_role(ctx: Context) -> str | None: + return application_services().apps.console.agent_role(ctx.request_context, ctx.app.id) + + +def _agent_app_info(record: AppRecord, role: str | None) -> AgentAppInfo: + return AgentAppInfo.model_validate({**asdict(record), "role": role}) + + +def agent_app_info(ctx: Context) -> AgentAppInfo: + record = application_services().apps.console.get(ctx.request_context, ctx.app.id) + return _agent_app_info(record, _stored_role(ctx)) + + +def update_agent_app_info(ctx: Context, patch: AgentAppInfoPatch) -> AgentAppInfo: + record = _update_app_info(ctx, patch.model_dump(exclude_unset=True, exclude_none=True)) + return _agent_app_info(record, _stored_role(ctx)) + + +def _update_app_info(ctx: Context, changes: dict[str, Any]) -> AppRecord: + return application_services().apps.console.update_fields( + ctx.request_context, ctx.app.id, cast(AppFieldChanges, changes) + ) + + +def _access_ready(ctx: Context) -> bool: + return application_services().apps.console.access_ready(ctx.request_context, ctx.app.id) + + +def _service_api(ctx: Context, enabled: bool) -> dict[str, Any]: + return {"enabled": enabled, "base_url": ctx.app.api_base_url} + + +def service_api[T: BaseModel](ctx: Context, model: type[T]) -> T: + return model.model_validate(_service_api(ctx, ctx.app.enable_api)) + + +def update_service_api[T: BaseModel](ctx: Context, enabled: bool, model: type[T]) -> T: + record = application_services().apps.console.set_api_enabled(ctx.request_context, ctx.app.id, enabled) + return model.model_validate(_service_api(ctx, record.enable_api)) + + +def _agent_service_api(ctx: Context, enabled: bool) -> AgentServiceApi: + ready = _access_ready(ctx) + return AgentServiceApi( + **_service_api(ctx, enabled and ready), + access_ready=ready, + api_rpm=ctx.app.api_rpm or 0, + api_rph=ctx.app.api_rph or 0, + ) + + +def agent_service_api(ctx: Context) -> AgentServiceApi: + return _agent_service_api(ctx, ctx.app.enable_api) + + +def update_agent_service_api(ctx: Context, enabled: bool) -> AgentServiceApi: + record = application_services().apps.console.set_api_enabled(ctx.request_context, ctx.app.id, enabled) + return _agent_service_api(ctx, record.enable_api) + + +def _webapp(ctx: Context) -> dict[str, Any]: + app = application_services().apps.console.get(ctx.request_context, ctx.app.id) + site = app.site or {} + base_url = app_base_url() + return { + **site, + "enabled": app.enable_site, + "access_token": site.get("code"), + "app_base_url": base_url, + "url": WebAppPath.of(ctx.app.mode).url(base_url, site.get("code")), + } + + +def webapp[T: BaseModel](ctx: Context, model: type[T]) -> T: + return model.model_validate(_webapp(ctx)) + + +def agent_webapp(ctx: Context) -> AgentWebApp: + data = _webapp(ctx) + ready = _access_ready(ctx) + return AgentWebApp.model_validate({**data, "enabled": data["enabled"] and ready, "access_ready": ready}) + + +def _update_site(ctx: Context, patch: BaseModel) -> None: + changes = patch.model_dump(exclude_unset=True, exclude_none=True) + enabled = changes.pop("enabled", None) + try: + if changes: + application_services().app_sites.update(ctx.request_context, ctx.app.id, AppSiteChanges(**changes)) + except (AppSiteNotFoundError, AppSiteAppNotFoundError) as error: + raise NotFound(str(error)) from error + if enabled is not None: + application_services().apps.console.set_site_enabled(ctx.request_context, ctx.app.id, enabled) + + +def update_webapp[T: BaseModel](ctx: Context, patch: BaseModel, model: type[T]) -> T: + _update_site(ctx, patch) + return webapp(ctx, model) + + +def update_agent_webapp(ctx: Context, patch: BaseModel) -> AgentWebApp: + _update_site(ctx, patch) + return agent_webapp(ctx) + + +def reset_webapp(ctx: Context) -> WebAppToken: + try: + site = application_services().app_sites.reset_access_token(ctx.request_context, ctx.app.id) + except (AppSiteNotFoundError, AppSiteAppNotFoundError) as error: + raise NotFound(str(error)) from error + base_url = app_base_url() + return WebAppToken( + access_token=site.code, app_base_url=base_url, url=WebAppPath.of(ctx.app.mode).url(base_url, site.code) + ) + + +def webapp_access(ctx: Context) -> WebAppAccess: + try: + access_mode = application_services().webapp_access.get_access_mode(app_id=ctx.app.id, app_code=None) + except WebAppAccessUnavailableError as error: + raise WebAppAccessUnavailable() from error + return WebAppAccess(access_mode=access_mode) + + +def update_webapp_access(ctx: Context, body: WebAppAccessPayload) -> WebAppAccess: + if body.access_mode not in _SETTABLE_ACCESS_MODES: + raise WebAppAccessModeConsoleOnly() + if body.access_mode == WebAppAccessMode.PUBLIC and not dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED: + raise WebAppPublicAccessDisabled() + try: + _ACCESS_POLICY.update_access_mode(ctx.app.id, body.access_mode) + except WebAppAccessUnavailableError as error: + raise WebAppAccessUnavailable() from error + return WebAppAccess(access_mode=body.access_mode) diff --git a/api/controllers/openapi/_contract.py b/api/controllers/openapi/_contract.py index 76d6386e77b..e5b4b0a290c 100644 --- a/api/controllers/openapi/_contract.py +++ b/api/controllers/openapi/_contract.py @@ -26,7 +26,7 @@ from pydantic import BaseModel, ValidationError from controllers.common.schema import query_params_from_model, query_params_from_request from controllers.openapi import openapi_ns from controllers.openapi._errors import ErrorBody -from controllers.openapi._hints import next_page_hint +from controllers.openapi._hints import next_page_hint, page_after from controllers.openapi._models import PaginationEnvelope from controllers.openapi._multipart import body_from_request from controllers.openapi._upload import file_fields @@ -41,21 +41,23 @@ _INJECTED_KWARGS: Final = frozenset({"ctx", "query", "body"}) def paginated(op: str) -> Callable: - """Fill the ``Next page`` hint on a bare ``PaginationEnvelope`` result (one with no hints - of its own). Sits inside ``accepts`` so it sees the validated ``query`` and the path - kwargs the hint echoes; only this layer knows both those and the op id. + """Add the ``Next page`` hint to a ``PaginationEnvelope`` result, unless the view already + built one for this op. Sits inside ``accepts`` so it sees the validated ``query`` and the + path kwargs the hint echoes; only this layer knows both those and the op id. """ def decorator(view: Callable) -> Callable: @wraps(view) def wrapper(*args: Any, **kwargs: Any) -> Any: result = view(*args, **kwargs) - if not isinstance(result, PaginationEnvelope) or result.hints: + if not isinstance(result, PaginationEnvelope) or any(h.op == op for h in result.hints): return result path_args = {name: value for name, value in kwargs.items() if name not in _INJECTED_KWARGS} - hint = next_page_hint(op=op, path_args=path_args, query=kwargs.get("query"), envelope=result) - if hint is not None: - result.hints = [hint] + page = page_after(page=result.page, limit=result.limit, has_more=result.has_more) + result.hints = [ + *result.hints, + *next_page_hint(op=op, path_args=path_args, query=kwargs.get("query"), page=page), + ] return result return wrapper @@ -109,10 +111,10 @@ def returns(code: int, model: type[BaseModel], description: str | None = None) - def wrapper(*args: Any, **kwargs: Any) -> Any: result = view(*args, **kwargs) if isinstance(result, BaseModel): - return result.model_dump(mode="json"), code + return result.model_dump(mode="json", by_alias=True), code if isinstance(result, tuple) and result and isinstance(result[0], BaseModel): payload, *rest = result - return (payload.model_dump(mode="json"), *rest) + return (payload.model_dump(mode="json", by_alias=True), *rest) return result openapi_ns.response(code, description or model.__name__, openapi_ns.models[model.__name__])(wrapper) diff --git a/api/controllers/openapi/_errors.py b/api/controllers/openapi/_errors.py index 4487531433c..9135384e4d9 100644 --- a/api/controllers/openapi/_errors.py +++ b/api/controllers/openapi/_errors.py @@ -1,13 +1,16 @@ """Canonical error contract for the /openapi/v1 surface. -``ErrorBody`` is the only wire shape an /openapi/v1 endpoint may emit for a -non-2xx response (RFC 8628 device-flow responses excepted — that shape is -mandated by the OAuth spec):: +``ErrorBody`` is the wire shape an /openapi/v1 endpoint emits for a non-2xx +response, with two exceptions: RFC 8628 device-flow responses (that shape is +mandated by the OAuth spec), and a failed DSL import or import confirm, which +answers 400 with the ``Import`` body (``status: failed`` and ``error``), as the +console does:: code str semantic error code (OpenApiErrorCode member) message str human-readable summary status int HTTP status, duplicated in the body hint str | None actionable next step for the caller + hints list[Hint] | None ops the caller can run next, with ready input details list[ErrorDetail] per-field validation breakdown {type, loc, msg} ``OpenApiErrorFormatter`` is injected into ``ExternalApi`` so every @@ -28,6 +31,7 @@ from typing import Any from pydantic import BaseModel from werkzeug.exceptions import HTTPException +from controllers.openapi._models import Hint from libs.external_api import http_status_message @@ -69,6 +73,32 @@ class OpenApiErrorCode(StrEnum): RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch" CATALOG_STALE = "catalog_stale" TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable" + RUN_NOT_FOUND = "run_not_found" + VERSION_NOT_FOUND = "version_not_found" + VERSION_NOT_RESTORABLE = "version_not_restorable" + DRAFT_NOT_FOUND = "draft_not_found" + ENV_VARIABLE_NOT_FOUND = "env_variable_not_found" + SECRET_MASK_UNKNOWN_ID = "secret_mask_unknown_id" + WEBAPP_ACCESS_REQUIRES_EE = "webapp_access_requires_ee" + WEBAPP_ACCESS_UNAVAILABLE = "webapp_access_unavailable" + NODE_TYPE_NOT_FOUND = "node_type_not_found" + NODE_NOT_FOUND = "node_not_found" + WEBAPP_ACCESS_MODE_CONSOLE_ONLY = "webapp_access_mode_console_only" + WEBAPP_PUBLIC_ACCESS_DISABLED = "webapp_public_access_disabled" + SECRET_MASK_NOT_SECRET = "secret_mask_not_secret" + MARKETPLACE_DISABLED = "marketplace_disabled" + MARKETPLACE_UNAVAILABLE = "marketplace_unavailable" + PLUGIN_INSTALL_FORBIDDEN = "plugin_install_forbidden" + PLUGIN_NOT_INSTALLED = "plugin_not_installed" + PROVIDER_NOT_FOUND = "provider_not_found" + CREDENTIAL_INVALID = "credential_invalid" + CREDENTIAL_OAUTH_ONLY = "credential_oauth_only" + CREDENTIAL_NOT_FOUND = "credential_not_found" + PLUGIN_SERVICE_UNAVAILABLE = "plugin_service_unavailable" + DSL_INVALID = "dsl_invalid" + DRAFT_CHANGED = "draft_changed" + APP_MODE_MISMATCH = "app_mode_mismatch" + SERVICE_API_DISABLED = "service_api_disabled" class ErrorDetail(BaseModel): @@ -86,6 +116,7 @@ class ErrorBody(BaseModel): message: str status: int hint: str | None = None + hints: list[Hint] | None = None details: list[ErrorDetail] | None = None @@ -122,17 +153,21 @@ class OpenApiError(HTTPException): code = 400 error_code: OpenApiErrorCode = OpenApiErrorCode.UNKNOWN hint: str | None = None + hints: list[Hint] | None = None def __init__( self, message: str | None = None, *, hint: str | None = None, + hints: list[Hint] | None = None, details: list[ErrorDetail] | None = None, ) -> None: super().__init__(description=message) if hint is not None: self.hint = hint + if hints is not None: + self.hints = hints self.details = details @@ -154,9 +189,10 @@ class OpenApiErrorFormatter: try: body = ErrorBody( code=self._resolve_code(e, status_code), - message=self._resolve_message(merged, status_code), + message=self._resolve_message(e, merged, status_code), status=status_code, hint=self._resolve_hint(e), + hints=e.hints if isinstance(e, OpenApiError) else None, details=self._extract_details(e, merged), ) wire = body.model_dump(mode="json", exclude_none=True) @@ -186,8 +222,8 @@ class OpenApiErrorFormatter: return error_code return str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN)) - def _resolve_message(self, merged: dict[str, Any], status_code: int) -> str: - if status_code >= 500: + def _resolve_message(self, e: Exception, merged: dict[str, Any], status_code: int) -> str: + if status_code >= 500 and not isinstance(e, OpenApiError): return _GENERIC_500_MESSAGE message = merged.get("message") if isinstance(message, str) and message: @@ -274,3 +310,170 @@ class InvalidFilePart(OpenApiError): # noqa: N818 code = 422 error_code = OpenApiErrorCode.INVALID_PARAM description = "A request part could not be used" + + +class RunNotFound(OpenApiError): # noqa: N818 + code = 404 + error_code = OpenApiErrorCode.RUN_NOT_FOUND + description = "No run with this id exists for this app." + + +class VersionNotFound(OpenApiError): # noqa: N818 + code = 404 + error_code = OpenApiErrorCode.VERSION_NOT_FOUND + description = "No version with this id exists for this app." + + +class WebAppAccessRequiresEE(OpenApiError): # noqa: N818 + code = 403 + error_code = OpenApiErrorCode.WEBAPP_ACCESS_REQUIRES_EE + description = "Web-app access control needs Dify Enterprise with web-app sign-in turned on." + + +class WebAppAccessUnavailable(OpenApiError): # noqa: N818 + code = 503 + error_code = OpenApiErrorCode.WEBAPP_ACCESS_UNAVAILABLE + description = "Web-app access could not be read or changed." + hint = "Enterprise is unreachable or did not answer." + + +class NodeTypeNotFound(OpenApiError): # noqa: N818 + code = 404 + error_code = OpenApiErrorCode.NODE_TYPE_NOT_FOUND + description = "No node type with this name exists on this server." + hint = "List the node types with get.node_type." + + +class NodeNotFound(OpenApiError): # noqa: N818 + code = 404 + error_code = OpenApiErrorCode.NODE_NOT_FOUND + description = "The draft has no node with this id." + + +class WebAppAccessModeConsoleOnly(OpenApiError): # noqa: N818 + code = 400 + error_code = OpenApiErrorCode.WEBAPP_ACCESS_MODE_CONSOLE_ONLY + description = "Member lists for access_mode private must be chosen in the Dify console." + hint = "Use public, private_all or sso_verified here." + + +class WebAppPublicAccessDisabled(OpenApiError): # noqa: N818 + code = 403 + error_code = OpenApiErrorCode.WEBAPP_PUBLIC_ACCESS_DISABLED + description = "This server does not allow web apps with access_mode public." + hint = "Use private_all or sso_verified." + + +class VersionNotRestorable(OpenApiError): # noqa: N818 + code = 400 + error_code = OpenApiErrorCode.VERSION_NOT_RESTORABLE + description = "Only a published version can be restored." + + +class DraftNotFound(OpenApiError): # noqa: N818 + code = 404 + error_code = OpenApiErrorCode.DRAFT_NOT_FOUND + description = "This app has no draft." + + +class EnvVariableNotFound(OpenApiError): # noqa: N818 + code = 404 + error_code = OpenApiErrorCode.ENV_VARIABLE_NOT_FOUND + description = "The draft has no environment variable with this id." + + +class SecretMaskUnknownId(OpenApiError): # noqa: N818 + code = 400 + error_code = OpenApiErrorCode.SECRET_MASK_UNKNOWN_ID + description = "The value is the masked secret placeholder, but env_id is not an existing secret." + + +class SecretMaskNotSecret(OpenApiError): # noqa: N818 + code = 400 + error_code = OpenApiErrorCode.SECRET_MASK_NOT_SECRET + description = "The value is the masked secret placeholder, which only a secret value_type can keep." + + +class MarketplaceDisabled(OpenApiError): # noqa: N818 + code = 503 + error_code = OpenApiErrorCode.MARKETPLACE_DISABLED + description = "Marketplace is disabled on this server." + hint = "Ask an admin to install the plugin." + + +class MarketplaceUnavailable(OpenApiError): # noqa: N818 + code = 503 + error_code = OpenApiErrorCode.MARKETPLACE_UNAVAILABLE + description = "The marketplace did not answer." + + +class PluginInstallForbidden(OpenApiError): # noqa: N818 + code = 403 + error_code = OpenApiErrorCode.PLUGIN_INSTALL_FORBIDDEN + description = "Plugin install is not allowed here." + + +class PluginNotInstalled(OpenApiError): # noqa: N818 + code = 404 + error_code = OpenApiErrorCode.PLUGIN_NOT_INSTALLED + description = "This plugin is not installed in the workspace." + hint = "List installed plugins with get.plugin." + + +class ProviderNotFound(OpenApiError): # noqa: N818 + code = 404 + error_code = OpenApiErrorCode.PROVIDER_NOT_FOUND + description = "No provider with this id in the workspace." + hint = "Provider ids look like langgenius/openai/openai; get.plugin shows them under provides." + + +class CredentialInvalid(OpenApiError): # noqa: N818 + code = 422 + error_code = OpenApiErrorCode.CREDENTIAL_INVALID + description = "The provider rejected these credentials; nothing was saved." + + +class CredentialOAuthOnly(OpenApiError): # noqa: N818 + code = 400 + error_code = OpenApiErrorCode.CREDENTIAL_OAUTH_ONLY + description = "This provider uses OAuth." + hint = "Ask the user to authorize it in the console." + + +class CredentialNotFound(OpenApiError): # noqa: N818 + code = 404 + error_code = OpenApiErrorCode.CREDENTIAL_NOT_FOUND + description = "No credential with this id for this provider that you can see." + hint = "List them with the describe op of the provider." + + +class PluginServiceUnavailable(OpenApiError): # noqa: N818 + code = 503 + error_code = OpenApiErrorCode.PLUGIN_SERVICE_UNAVAILABLE + description = "Plugin service is unavailable." + + +class DslInvalid(OpenApiError): # noqa: N818 + code = 422 + error_code = OpenApiErrorCode.DSL_INVALID + description = "The DSL has problems the import refuses; each is in details with its location" + + +class DraftChanged(OpenApiError): # noqa: N818 + code = 409 + error_code = OpenApiErrorCode.DRAFT_CHANGED + description = "The draft changed after it was exported. Export it again and reapply your edits." + + +class AppModeMismatch(OpenApiError): # noqa: N818 + code = 422 + error_code = OpenApiErrorCode.APP_MODE_MISMATCH + description = "This op does not serve apps of this mode." + hint = "describe.console_app shows the app's mode." + + +class ServiceApiDisabled(OpenApiError): # noqa: N818 + code = 403 + error_code = OpenApiErrorCode.SERVICE_API_DISABLED + description = "This app's Service API is turned off." + hint = "A workspace admin can turn it on; hints holds the op." diff --git a/api/controllers/openapi/_hints.py b/api/controllers/openapi/_hints.py index 102b48868e7..e8ecf84771f 100644 --- a/api/controllers/openapi/_hints.py +++ b/api/controllers/openapi/_hints.py @@ -16,7 +16,7 @@ from typing import Any, Final, Protocol, runtime_checkable from pydantic import BaseModel, ValidationError -from controllers.openapi._models import Hint, Hinted, PageQuery, PaginationEnvelope +from controllers.openapi._models import Hint, Hinted, PageQuery logger = logging.getLogger(__name__) @@ -32,16 +32,26 @@ class _ClosableStream(Protocol): def close(self) -> None: ... +def page_after(*, page: int, limit: int, has_more: bool) -> dict[str, Any] | None: + """The page-number input of the next page, or None on the last one.""" + return PageQuery(page=page + 1, limit=limit).model_dump() if has_more else None + + +def cursor_after(*, last_id: str | None, has_more: bool) -> dict[str, Any] | None: + """The cursor input of the next page, or None on the last one.""" + return {"last_id": last_id} if has_more and last_id else None + + def next_page_hint( - *, op: str, path_args: Mapping[str, Any], query: BaseModel | None, envelope: PaginationEnvelope[Any] -) -> Hint | None: - if not envelope.has_more: - return None + *, op: str, path_args: Mapping[str, Any], query: BaseModel | None, page: Mapping[str, Any] | None +) -> list[Hint]: + """The call's own input with `page` (from `page_after` or `cursor_after`) on top; none on the last page.""" + if page is None: + return [] params: dict[str, Any] = dict(path_args) if query is not None: params |= query.model_dump(exclude_none=True) - params |= PageQuery(page=envelope.page + 1, limit=envelope.limit).model_dump() - return Hint(summary="Next page", op=op, input=params) + return [Hint(summary="Next page", op=op, input=params | dict(page))] def _wanted_event(chunk: str, event: str) -> dict[str, Any] | None: diff --git a/api/controllers/openapi/_i18n.py b/api/controllers/openapi/_i18n.py new file mode 100644 index 00000000000..5ab974891c7 --- /dev/null +++ b/api/controllers/openapi/_i18n.py @@ -0,0 +1,23 @@ +"""One string out of a plugin's many-language text, for the account reading it.""" + +from collections.abc import Mapping +from typing import Final + +from pydantic import BaseModel + +_FALLBACK: Final = "en_US" + + +def localized(text: BaseModel | Mapping[str, str] | None, language: str | None) -> str | None: + """The account's language, else `en_US`, else None. Takes an I18nObject (of any of the + runtimes) or its dumped mapping. Plugin manifests always carry `en_US`; marketplace text + may not, and then nothing is guessed.""" + if text is None: + return None + values = text.model_dump() if isinstance(text, BaseModel) else text + keys = (language.replace("-", "_"), _FALLBACK) if language else (_FALLBACK,) + for key in keys: + value = values.get(key) + if value: + return value + return None diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 9958817b288..7d5362035cf 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -2,19 +2,34 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import datetime -from typing import Any, Literal, Self +from enum import StrEnum +from typing import Any, Final, Literal, Protocol, Self -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator +from constants.languages import supported_language from constants.oauth_bearer import SubjectType from controllers.common.human_input import HumanInputFormSubmitPayload +from controllers.openapi._i18n import localized from controllers.openapi._upload import UploadPart, UploadParts -from enums import DeploymentEdition -from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, uuid_value -from models.model import AppMode +from core.plugin.entities.plugin import PluginCategory +from core.plugin.entities.plugin_daemon import PluginInstallTaskStatus +from core.tools.entities.tool_entities import ToolProviderType +from enums import DeploymentEdition, WebAppAccessMode +from fields.workflow_run_fields import WorkflowRunPaginationResponse +from graphon.enums import BuiltinNodeTypes, WorkflowExecutionStatus +from graphon.model_runtime.entities.model_entities import ModelType +from graphon.nodes.tool.entities import ToolInputType +from graphon.variables import SegmentType +from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, to_timestamp, uuid_value +from models.enums import WorkflowRunTriggeredFrom +from models.model import AppMode, IconType from services.entities.app_entities import SupportedAppType -from services.entities.dsl_entities import Import +from services.entities.dsl_entities import CheckDependenciesResult, Import, ImportMode +from services.workflow.graph_check import GraphIssue +from services.workflow.graph_diff import WorkflowDiff # Server-side cap on `limit` query param for /openapi/v1/* list endpoints. MAX_PAGE_LIMIT = 100 @@ -32,13 +47,13 @@ class MessageMetadata(BaseModel): class Hint(BaseModel): - """A next step the caller can hand straight to `call --input `.""" + """A next step: the op id to call and its ready input.""" summary: str op: str input: dict[str, Any] = Field(description="Ready-to-send input for `op`; unknown values are null") form: list[dict[str, Any]] | None = Field( - default=None, description="Form fields behind `input.inputs`, copied from the pausing event" + default=None, description="Form fields behind the hint's input: a paused run's form inputs, or credentials" ) @@ -103,10 +118,10 @@ class AppInfo(BaseModel): class AppDescribeInfo(AppInfo): updated_at: str | None = None service_api_enabled: bool - is_agent: bool = False + is_agent: bool = Field(False, description="True for agent-chat and advanced-chat apps") -class AppDescribeResponse(BaseModel): +class AppDescribeResponse(Hinted): info: AppDescribeInfo | None = None parameters: dict[str, Any] | None = Field(default=None) input_schema: dict[str, Any] | None = Field(default=None) @@ -189,7 +204,6 @@ class WorkspaceSummaryResponse(BaseModel): name: str role: str status: str - current: bool class WorkspaceListResponse(PaginationEnvelope[WorkspaceSummaryResponse]): @@ -207,7 +221,6 @@ class WorkspaceDetailResponse(BaseModel): name: str role: str status: str - current: bool created_at: str | None = None @@ -309,7 +322,11 @@ class AppListQuery(PageQuery): workspace_id: UUIDStr mode: SupportedAppType | None = None - name: str | None = Field(None, max_length=200) + name: str | None = Field( + None, + max_length=200, + description="Filter by a name substring; an app id returns that one app and ignores `mode`", + ) class _ConversationFields(BaseModel): @@ -337,21 +354,23 @@ class RunPayloadBase(BaseModel): """What every run takes; each mode's payload adds its own fields and forbids the rest.""" inputs: dict[str, Any] = Field( + default_factory=dict, description=( "Variables declared by the app. The exact shape is per app: read `input_schema` from " "describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, " - "or a local file in `files`, not both." - ) + "or a local path in `files`, not both." + ), ) files: UploadParts | None = Field( default=None, description=( - "Local files keyed by the app's file variable name; the server uploads each one and sets " - "`inputs[]`. Send a list (part name `files[][]`) for a file-list variable" + "Local file paths keyed by the app's file variable name; each file is uploaded and becomes " + "that variable's value. Give a list of paths for a file-list variable" ), ) attachments: list[UploadPart] | None = Field( - default=None, description="Local files attached to the run itself (the app's `sys.files`), not to a variable" + default=None, + description="Local file paths attached to the run itself (the app's `sys.files`), not to a variable", ) workspace_id: UUIDStrOrEmpty | None = Field(default=None, description="Workspace that owns the app") @@ -360,6 +379,10 @@ class WorkflowRunPayload(RunPayloadBase, _WorkflowVersionFields): model_config = ConfigDict(extra="forbid") +class DraftWorkflowRunPayload(RunPayloadBase): + model_config = ConfigDict(extra="forbid") + + class ChatRunPayload(RunPayloadBase, _ConversationFields): model_config = ConfigDict(extra="forbid") @@ -470,6 +493,100 @@ class MemberActionResponse(BaseModel): result: Literal["success"] = "success" +class MarketplacePluginQuery(PageQuery): + model_config = ConfigDict(extra="forbid") + + query: str = Field("", description="Words to search for; empty lists the most installed plugins") + category: PluginCategory | None = Field(None, description="Only plugins of this category") + + +class MarketplacePluginRow(BaseModel): + plugin_id: str + identifier: str = Field(description="Latest versioned id; pass it to install.plugin") + version: str + category: str + label: str | None + brief: str | None + authorized_category: str | None = Field( + description=( + "Who vouches for the plugin: langgenius (official), partner or community; " + "workspace install-scope rules check this" + ) + ) + install_count: int + installed: bool + installed_version: str | None + + +class MarketplacePluginListResponse(PaginationEnvelope[MarketplacePluginRow]): + pass + + +class PluginListQuery(BaseModel): + model_config = ConfigDict(extra="forbid") + + category: PluginCategory | None = Field(None, description="Only plugins of this category") + + +class PluginProvides(BaseModel): + model_provider: str | None = Field(description="Model provider id this plugin adds, for describe.model_provider") + tool_provider: str | None = Field(description="Tool provider id this plugin adds, for describe.tool_provider") + + +class PluginRow(BaseModel): + plugin_id: str + identifier: str + version: str + latest_version: str | None + category: str + label: str | None + source: str + provides: PluginProvides + + +class PluginListResponse(Hinted): + data: list[PluginRow] + + +class PluginInstallPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + identifiers: list[str] = Field( + min_length=1, + description=( + "Versioned plugin ids such as langgenius/openai:0.2.1@sha256…, from get.marketplace.plugin " + "(identifier) or check.console_app.dependency" + ), + ) + + +class PluginTaskStartResponse(Hinted): + task_id: str + all_installed: bool + + +class PluginTaskItem(BaseModel): + plugin_id: str + identifier: str + status: PluginInstallTaskStatus + message: str + + +class PluginTaskResponse(Hinted): + task_id: str + status: PluginInstallTaskStatus + plugins: list[PluginTaskItem] + + +class PluginDeleteResponse(BaseModel): + plugin_id: str + deleted: bool + + +class CheckDependenciesResponse(CheckDependenciesResult, Hinted): + pass + + class TaskStopResponse(BaseModel): """200 body for POST /apps//tasks/:stop. The handler always returns {"result": "success"}, so `result` is required (no default) — the generated contract @@ -492,20 +609,33 @@ class AppDslImportPayload(BaseModel): icon: str | None = Field(None) icon_background: str | None = Field(None) app_id: str | None = Field(None, description="Existing app ID to overwrite (workflow/advanced-chat apps only)") + draft_hash: str | None = Field( + None, + description="draft_hash from the export or restore this import is based on. The import fails if the " + "draft's graph, features, environment variables or conversation variables changed since. Requires app_id", + ) + _source: str = PrivateAttr() @model_validator(mode="after") def _validate_source_by_mode(self) -> AppDslImportPayload: - if self.mode == "yaml-content" and not self.yaml_content: - raise ValueError("yaml_content is required when mode is 'yaml-content'") - if self.mode == "yaml-url" and not self.yaml_url: - raise ValueError("yaml_url is required when mode is 'yaml-url'") + if self.draft_hash is not None and not self.app_id: + raise ValueError("draft_hash is only valid when app_id names the app to overwrite") + source = self.yaml_url if self.mode == ImportMode.YAML_URL else self.yaml_content + if not source: + raise ValueError(f"{self.mode.replace('-', '_')} is required when mode is '{self.mode}'") + self._source = source return self + @property + def source(self) -> str: + """The URL or the YAML text, whichever the mode names.""" + return self._source + class AppDslExportQuery(BaseModel): """Query parameters for GET /apps//dsl.""" - include_secret: bool = Field(False, description="Include encrypted secret values in the exported DSL") + include_secret: bool = Field(False, description="Include secret values in plaintext in the exported DSL") workflow_id: UUIDStr | None = Field( None, description="Export a specific workflow version instead of the current draft" ) @@ -515,16 +645,75 @@ class AppDslExportResponse(BaseModel): """Export DSL response.""" data: str = Field(..., description="DSL YAML string") + draft_hash: str | None = Field( + None, + description="Hash of the draft's graph, features, environment variables and conversation variables; " + "pass it to the import to refuse overwriting newer edits", + ) class AppDslImportResponse(Import, Hinted): """`Import` plus the server-built next step for a pending import.""" -class FormSubmitResponse(BaseModel): - """Empty 200 body for POST /apps//human-input-forms/:submit. `extra='forbid'` - pins `additionalProperties: false` so the generated contract is an exact `{}` rather - than an under-annotated open object.""" +class DslCheckPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + yaml_content: str = Field(description="The DSL to check, as YAML text") + app_id: str | None = Field(None, description="Check against this existing app's mode, as an overwrite import would") + + +class DslIssueRow(BaseModel): + code: str + severity: str = Field(description="error: import refuses it; warning: import allows it, the release check does not") + node_id: str | None + loc: list[str | int] + message: str + + @classmethod + def of(cls, issue: GraphIssue) -> DslIssueRow: + return cls( + code=issue.code, + severity=issue.code.severity, + node_id=issue.node_id, + loc=list(issue.loc), + message=issue.message, + ) + + +class DslCheckResponse(Hinted): + valid: bool = Field(description="No error-severity issues") + issues: list[DslIssueRow] + + +class ReleaseCheckName(StrEnum): + DRAFT_VALID = "draft_valid" + TESTED = "tested" + + +class ReleaseCheckRow(BaseModel): + name: ReleaseCheckName + passed: bool + detail: str + + +class ReleaseState(BaseModel): + service_api_enabled: bool + webapp_enabled: bool + + +class ReleaseCheckResponse(Hinted): + ready: bool = Field(description="Every check passed; show the human `changes` and ask before publishing") + checks: list[ReleaseCheckRow] + issues: list[DslIssueRow] + state: ReleaseState + changes: WorkflowDiff + + +class FormSubmitResponse(Hinted): + """200 body for POST /apps//human-input-forms/:submit: the hint to follow the + resumed run. `extra='forbid'` pins `additionalProperties: false` so the generated contract + is exact rather than an under-annotated open object.""" model_config = ConfigDict(extra="forbid") @@ -534,7 +723,7 @@ class OpenApiFormSubmitPayload(HumanInputFormSubmitPayload): files: UploadParts | None = Field( default=None, - description="Local files keyed by the form's file input name, same convention as the run ops' `files`", + description="Local file paths keyed by the form's file input name, same convention as the run ops' `files`", ) @@ -544,3 +733,600 @@ class HumanInputFormDefinitionResponse(BaseModel): resolved_default_values: dict[str, str] user_actions: list[dict[str, Any]] = Field(default_factory=list) expiration_time: int | None = None + + +class RunListQuery(BaseModel): + last_id: UUIDStr | None = Field(None, description="Cursor: id of the last run on the previous page") + limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) + status: ( + Literal[ + WorkflowExecutionStatus.RUNNING, + WorkflowExecutionStatus.SUCCEEDED, + WorkflowExecutionStatus.FAILED, + WorkflowExecutionStatus.STOPPED, + WorkflowExecutionStatus.PARTIAL_SUCCEEDED, + ] + | None + ) = None + triggered_from: Literal[WorkflowRunTriggeredFrom.DEBUGGING, WorkflowRunTriggeredFrom.APP_RUN] = Field( + WorkflowRunTriggeredFrom.DEBUGGING, + description="debugging: draft test runs, as in the console; app-run: real use", + ) + + +class RunListResponse(WorkflowRunPaginationResponse, Hinted): + """Cursor page of runs; `hints` carries the next page.""" + + +class PublishPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + marked_name: str = Field("", max_length=20, description="Version name") + marked_comment: str = Field("", max_length=100, description="Version note") + + +class PublishResponse(BaseModel): + version_id: str + created_at: int + warning: str | None = Field(None, description="Variable references that may read a skipped branch") + + +class VersionListQuery(PageQuery): + named_only: bool = Field(False, description="Only versions that have a name") + + +class VersionRow(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + marked_name: str + marked_comment: str + created_by: str | None = None + created_at: int + current: bool = False + + @field_validator("created_at", mode="before") + @classmethod + def _timestamp(cls, value: datetime | int) -> int | None: + return to_timestamp(value) + + +class VersionListResponse(Hinted): + """Page of published versions, newest first; there is no total, `hints` carries the next page.""" + + page: int + limit: int + has_more: bool + data: list[VersionRow] + + +class EnvVariableRow(BaseModel): + id: str = Field(description="What set and delete take to address this variable") + name: str = Field(description="What nodes use to refer to this variable") + description: str = "" + value_type: str + value: Any = Field(description="The value; a secret with a value is masked, an empty one reads as empty") + + +class EnvVariableListResponse(BaseModel): + data: list[EnvVariableRow] + + +class EnvVariableValueType(StrEnum): + """Value types the draft environment-variable ``set`` op accepts. + + A curated subset of ``SegmentType``: what the console's environment-variable editor + allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py). + Members reference ``SegmentType.*.value`` so the subset relationship is type-checked. + """ + + STRING = SegmentType.STRING.value + NUMBER = SegmentType.NUMBER.value + SECRET = SegmentType.SECRET.value + + +class EnvVariableSetPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(description="Variable name") + value_type: EnvVariableValueType = Field(description="string, number or secret") + value: Any = Field(description="The value; sending the masked value of an existing secret keeps the stored one") + description: str = Field("", description="What the variable is for") + + +class RestoreResponse(BaseModel): + result: Literal["success"] + draft_hash: str = Field( + description="Hash of the restored draft's graph, features, environment variables and conversation " + "variables; pass it to a DSL import as draft_hash" + ) + + +class NodeTypeRow(BaseModel): + type: str + version: str + + +class NodeTypeListResponse(BaseModel): + data: list[NodeTypeRow] + + +class NodeTypeDetailResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + type: str + version: str + schema_: dict[str, Any] = Field(alias="schema") + default_config: dict[str, Any] + + +AppIconType = Literal[IconType.EMOJI, IconType.IMAGE, IconType.LINK] + + +class CreateAppPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, description="App name") + description: str | None = Field(default=None, max_length=400, description="App description") + icon_type: AppIconType | None = Field(default=None, description="emoji, image or link") + icon: str | None = Field(default=None, description="Emoji, file id or URL, per icon_type") + icon_background: str | None = Field(default=None, description="Background colour for an emoji icon") + + +class CreatedAppResponse(BaseModel): + app_id: str + mode: str + name: str + + +class AppSettingsInfo(BaseModel): + name: str + description: str | None = None + icon_type: str | None = None + icon: str | None = None + icon_background: str | None = None + max_active_requests: int | None = None + + +class ChatAppInfo(AppSettingsInfo): + use_icon_as_answer_icon: bool = False + + +class AgentAppInfo(ChatAppInfo): + role: str | None = None + + +class AppInfoPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, description="App name") + description: str | None = Field(default=None, max_length=400, description="Pass an empty string to clear") + icon_type: AppIconType | None = Field(default=None, description="emoji, image or link") + icon: str | None = Field(default=None, description="Emoji, file id or URL, per icon_type") + icon_background: str | None = Field(default=None, description="Background colour for an emoji icon") + max_active_requests: int | None = Field( + default=None, + ge=0, + description="Concurrent run cap; 0 uses the server default. The server-wide cap still applies", + ) + + +class ChatAppInfoPatch(AppInfoPatch): + use_icon_as_answer_icon: bool | None = Field(default=None, description="Show the app icon on answers") + + +class AgentAppInfoPatch(ChatAppInfoPatch): + role: str | None = Field(default=None, max_length=255, description="The agent's role; empty string clears it") + + +class ServiceApi(BaseModel): + enabled: bool + base_url: str + + +class AgentServiceApi(ServiceApi): + access_ready: bool + api_rpm: int = 0 + api_rph: int = 0 + + +class ServiceApiPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + + enabled: bool = Field(description="Turn the app's Service API on or off") + + +class NodeRunPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + inputs: dict[str, Any] = Field( + default_factory=dict, + description="Overrides for what the last draft run saved, keyed by variable reference such as #llm.text#", + ) + files: UploadParts | None = Field( + default=None, + description=( + "Local file paths keyed by the file variable name (a start variable or #node.var#); each is uploaded" + ), + ) + + +class AdvancedChatNodeRunPayload(NodeRunPayload): + query: str = Field(default="", description="The user message the node sees as sys.query") + + +class WebApp(BaseModel): + enabled: bool + access_token: str | None = None + app_base_url: str + url: str | None = None + title: str | None = None + description: str | None = None + icon_type: str | None = None + icon: str | None = None + icon_background: str | None = None + default_language: str | None = None + copyright: str | None = None + privacy_policy: str | None = None + custom_disclaimer: str | None = None + + +class WorkflowWebApp(WebApp): + show_workflow_steps: bool = False + + +class ChatWebApp(WebApp): + chat_color_theme: str | None = None + chat_color_theme_inverted: bool = False + use_icon_as_answer_icon: bool = False + input_placeholder: str | None = None + + +class AdvancedChatWebApp(ChatWebApp): + show_workflow_steps: bool = False + + +class AgentWebApp(ChatWebApp): + access_ready: bool + + +class WebAppPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + + enabled: bool | None = Field(default=None, description="Turn the web app on or off") + title: str | None = Field(default=None, description="Page title") + description: str | None = Field(default=None, description="Page description") + icon_type: AppIconType | None = Field(default=None, description="emoji, image or link") + icon: str | None = Field(default=None, description="Emoji, file id or URL, per icon_type") + icon_background: str | None = Field(default=None, description="Background colour for an emoji icon") + default_language: str | None = Field(default=None, description="Language code, e.g. en-US") + copyright: str | None = Field(default=None, description="Footer copyright text") + privacy_policy: str | None = Field(default=None, description="Privacy policy URL") + custom_disclaimer: str | None = Field(default=None, description="Disclaimer shown on the page") + + @field_validator("default_language") + @classmethod + def _language(cls, value: str | None) -> str | None: + return value if value is None else supported_language(value) + + +class WorkflowWebAppPatch(WebAppPatch): + show_workflow_steps: bool | None = Field(default=None, description="Show each node step to users") + + +class ChatWebAppPatch(WebAppPatch): + chat_color_theme: str | None = Field(default=None, description="Chat colour, e.g. #1C64F2") + chat_color_theme_inverted: bool | None = Field(default=None, description="Invert the chat colours") + use_icon_as_answer_icon: bool | None = Field(default=None, description="Show the app icon on answers") + input_placeholder: str | None = Field(default=None, description="Placeholder of the chat input") + + +class AdvancedChatWebAppPatch(ChatWebAppPatch): + show_workflow_steps: bool | None = Field(default=None, description="Show each node step to users") + + +class WebAppToken(BaseModel): + access_token: str | None = None + app_base_url: str + url: str | None = None + + +class WebAppAccess(BaseModel): + access_mode: str + + +class WebAppAccessPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + access_mode: WebAppAccessMode = Field( + description="public, private_all or sso_verified. Choose specific members (private) in the Dify console" + ) + + +class _FormOption(Protocol): + value: str + + +class _FormShowOn(Protocol): + variable: str + value: str + + +class CredentialFormField(BaseModel): + name: str + type: str + required: bool + label: str | None + placeholder: str | None + options: list[str] | None = Field(description="Allowed values, when the field is a choice") + show_on: list[dict[str, str]] = Field(description="Show this field only when these other fields have these values") + + @classmethod + def of( + cls, + *, + name: str, + type: object, + required: bool, + label: BaseModel | None, + placeholder: BaseModel | None, + options: Sequence[_FormOption] | None, + show_on: Sequence[_FormShowOn] = (), + language: str | None, + ) -> CredentialFormField: + """A model or tool provider's credential form field, cut to what a caller needs.""" + return cls( + name=name, + type=str(type), + required=required, + label=localized(label, language), + placeholder=localized(placeholder, language), + options=[option.value for option in options] if options else None, + show_on=[{"variable": rule.variable, "value": rule.value} for rule in show_on], + ) + + +class CredentialRef(BaseModel): + id: str + name: str | None + + +class ModelProviderRow(BaseModel): + provider: str = Field(description="Provider id such as langgenius/openai/openai") + label: str | None + model_types: list[str] + configured: bool + active_credential: CredentialRef | None + + +class CustomModelRow(BaseModel): + model: str + model_type: str + active_credential: CredentialRef | None + + +class ModelProviderDetailResponse(ModelProviderRow, Hinted): + credential_form: list[CredentialFormField] + credentials: list[CredentialRef] + custom_model_form: list[CredentialFormField] | None + custom_models: list[CustomModelRow] + + +_CREDENTIALS_DESCRIPTION: Final = "Secret values, keyed by the field names in credential_form of the describe op." +_CREDENTIALS_UPDATE_DESCRIPTION: Final = ( + _CREDENTIALS_DESCRIPTION + " Send [__HIDDEN__] for a secret you keep unchanged." +) + + +class ProviderCredentialCreatePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + credentials: dict[str, Any] = Field(description=_CREDENTIALS_DESCRIPTION) + name: str | None = Field(None, description="Credential name; the server makes one when absent") + + +class ProviderCredentialUpdatePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + credentials: dict[str, Any] = Field(description=_CREDENTIALS_UPDATE_DESCRIPTION) + name: str | None = None + + +class CredentialWriteResponse(Hinted): + id: str + name: str | None + active: bool + + +class ToolProviderRow(BaseModel): + provider: str = Field(description="Tool provider id such as langgenius/tavily/tavily") + label: str | None + configured: bool = Field(description="Ready to use: needs no credential, or the workspace has one") + credential_types: list[str] = Field(description="api-key can be set here; oauth2 needs the console") + + +class ToolProviderDetailResponse(ToolProviderRow, Hinted): + credential_form: list[CredentialFormField] = Field(description="Fields of an api-key credential") + credentials: list[CredentialRef] + default_credential: CredentialRef | None + + +class ToolCredentialCreatePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + credentials: dict[str, Any] = Field(description=_CREDENTIALS_DESCRIPTION) + name: str | None = Field(None, max_length=30) + + +class ToolCredentialUpdatePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + credentials: dict[str, Any] = Field(description=_CREDENTIALS_UPDATE_DESCRIPTION) + name: str | None = Field(None, max_length=30) + + +class ToolSource(StrEnum): + """Tool provider types a workflow tool node can call; values match the node's provider_type.""" + + BUILTIN = ToolProviderType.BUILT_IN.value + WORKFLOW = ToolProviderType.WORKFLOW.value + API = ToolProviderType.API.value + MCP = ToolProviderType.MCP.value + + +class ToolListQuery(PageQuery): + model_config = ConfigDict(extra="forbid") + + query: str = Field("", description="Words to match in the tool name or label, or the provider id or label") + provider_type: ToolSource | None = Field(None, description="Only tools of this provider type") + provider: str | None = Field(None, description="Only this provider's tools") + + +class ToolInputValue(BaseModel): + type: ToolInputType = ToolInputType.CONSTANT + value: Any = None + + +class ToolParameterRow(BaseModel): + name: str + label: str | None + type: str + form: str = Field(description="llm: goes in tool_parameters; form: goes in tool_configurations; schema: fixed") + required: bool + default: Any + options: list[str] + min: float | None + max: float | None + llm_description: str | None + + +TOOL_NODE_VERSION: Final = "2" + + +class ToolNodeTemplate(BaseModel): + """A tool node's data with every value as {type, value}.""" + + type: str = BuiltinNodeTypes.TOOL + title: str + provider_type: str + provider_id: str + provider_name: str + plugin_id: str | None + plugin_unique_identifier: str | None + tool_name: str + tool_label: str + tool_node_version: str = TOOL_NODE_VERSION + tool_parameters: dict[str, ToolInputValue] + tool_configurations: dict[str, ToolInputValue] + + +class ToolRow(BaseModel): + provider: str + provider_type: str + provider_label: str | None + configured: bool = Field( + description=( + "Ready to use: needs no credential, or the workspace has one. MCP: authorize or refresh in the console" + ) + ) + name: str + label: str + description: str | None + parameters: list[ToolParameterRow] + node_data: ToolNodeTemplate = Field( + description=( + "Use as the tool node's data; fill every null value; pass upstream values as " + '{type: mixed, value: "{{#node.var#}}"} or {type: variable, value: [node, var]}' + ) + ) + + +class ToolListResponse(PaginationEnvelope[ToolRow]): + pass + + +class ModelListQuery(PageQuery): + model_config = ConfigDict(extra="forbid") + + provider: str | None = Field(None, description="Only this provider's models, such as langgenius/openai/openai") + model_type: ModelType | None = Field(None, description="Only models of this type") + query: str = Field("", description="Words to match in the model name, label or provider") + + +class LlmModelBlock(BaseModel): + provider: str + name: str + mode: str + completion_params: dict[str, Any] = Field(default_factory=dict) + + +class ModelRow(BaseModel): + provider: str + provider_label: str | None + model: str + model_type: str + label: str | None + status: str = Field(description="active means usable now; anything else needs the provider set up") + features: list[str] + node_model: LlmModelBlock | None = Field(None, description="LLM rows: paste as the node's model") + + +class ModelListResponse(PaginationEnvelope[ModelRow]): + pass + + +KNOWLEDGE_TOP_K: Final = 4 + + +class MultipleRetrievalFragment(BaseModel): + top_k: int = KNOWLEDGE_TOP_K + reranking_enable: bool = False + + +class KnowledgeRetrievalFragment(BaseModel): + dataset_ids: list[str] + retrieval_mode: Literal["multiple"] = "multiple" + multiple_retrieval_config: MultipleRetrievalFragment = Field(default_factory=MultipleRetrievalFragment) + + +class KnowledgeBaseListQuery(PageQuery): + model_config = ConfigDict(extra="forbid") + + query: str = Field("", description="Words to match in the knowledge base name") + + +class KnowledgeBaseRow(BaseModel): + id: str + name: str + description: str | None + provider: str = Field(description="vendor: indexed in Dify; external: an external knowledge API") + indexing_technique: str | None + document_count: int + usable: bool = Field(description="false when its embedding model isn't available in this workspace") + node_data: KnowledgeRetrievalFragment = Field( + description=( + "Merge into a knowledge-retrieval node's data and set query_variable_selector; " + "to search several bases, join their dataset_ids. For rerank or weights read describe node_type" + ) + ) + + +class KnowledgeBaseListResponse(PaginationEnvelope[KnowledgeBaseRow]): + pass + + +class ModelRef(BaseModel): + model_config = ConfigDict(extra="forbid") + + model: str = Field(description="Model name, as in get.model") + model_type: ModelType + + +class ModelCredentialCreatePayload(ModelRef): + credentials: dict[str, Any] = Field(description=_CREDENTIALS_DESCRIPTION) + name: str | None = None + + +class ModelCredentialUpdatePayload(ModelRef): + credentials: dict[str, Any] = Field(description=_CREDENTIALS_UPDATE_DESCRIPTION) + name: str | None = None diff --git a/api/controllers/openapi/_search.py b/api/controllers/openapi/_search.py new file mode 100644 index 00000000000..aacd9d95f9f --- /dev/null +++ b/api/controllers/openapi/_search.py @@ -0,0 +1,8 @@ +"""Plain-words filtering for list ops whose source returns everything.""" + + +def matches(words: str, *texts: str | None) -> bool: + if not words: + return True + needle = words.casefold() + return any(needle in text.casefold() for text in texts if text) diff --git a/api/controllers/openapi/app_create.py b/api/controllers/openapi/app_create.py new file mode 100644 index 00000000000..bd40188d962 --- /dev/null +++ b/api/controllers/openapi/app_create.py @@ -0,0 +1,65 @@ +"""Create workflow and advanced-chat apps with the editor's starter draft; export then gives the starting DSL. + +A workflow starts empty; an advanced-chat app starts with Start -> LLM -> Answer, the LLM without a model. +""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import Final, Literal + +from flask_restx import Resource + +from controllers.common.rbac import RBACPermission +from controllers.openapi import openapi_ns +from controllers.openapi._contract import Example, Kind, endpoint +from controllers.openapi._models import CreateAppPayload, CreatedAppResponse +from controllers.openapi.auth.requirements import EDITOR_ROLES, CheckAppQuota, workspace_write_guards +from extensions.ext_application_services import application_services +from machinery.context import RequestContext +from models import AppMode +from services.entities.app_entities import CreateAppParams + +_CREATE_GUARDS: Final = workspace_write_guards( + RBACPermission.APP_CREATE_AND_MANAGEMENT, roles=EDITOR_ROLES, extra=(CheckAppQuota(),) +) + + +def _create( + ctx: RequestContext, mode: Literal[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT], body: CreateAppPayload +) -> tuple[CreatedAppResponse, HTTPStatus]: + params = CreateAppParams(mode=mode.value, **body.model_dump(exclude_none=True)) + app = application_services().apps.console.create_with_draft(ctx, params) + return CreatedAppResponse(app_id=app.id, mode=mode.value, name=app.name), HTTPStatus.CREATED + + +@openapi_ns.route("/workspaces//apps/workflow") +class WorkflowAppCreateApi(Resource): + @endpoint( + context="workspace", + op="create.console_app.workflow", + kind=Kind.OBJECT, + summary="Create a blank workflow app; export its DSL to start building", + examples=(Example(title="Create a workflow app", input={"name": "Release notes"}),), + requirements=_CREATE_GUARDS, + body=CreateAppPayload, + returns=(HTTPStatus.CREATED, CreatedAppResponse, "Created"), + ) + def post(self, ctx: RequestContext, workspace_id: str, *, body: CreateAppPayload): + return _create(ctx, AppMode.WORKFLOW, body) + + +@openapi_ns.route("/workspaces//apps/advanced-chat") +class AdvancedChatAppCreateApi(Resource): + @endpoint( + context="workspace", + op="create.console_app.advanced_chat", + kind=Kind.OBJECT, + summary="Create an advanced-chat app with a Start, LLM and Answer draft; export its DSL to start building", + examples=(Example(title="Create an advanced-chat app", input={"name": "Support bot"}),), + requirements=_CREATE_GUARDS, + body=CreateAppPayload, + returns=(HTTPStatus.CREATED, CreatedAppResponse, "Created"), + ) + def post(self, ctx: RequestContext, workspace_id: str, *, body: CreateAppPayload): + return _create(ctx, AppMode.ADVANCED_CHAT, body) diff --git a/api/controllers/openapi/app_dsl.py b/api/controllers/openapi/app_dsl.py index b7358358b7b..91e9d030106 100644 --- a/api/controllers/openapi/app_dsl.py +++ b/api/controllers/openapi/app_dsl.py @@ -1,39 +1,84 @@ from __future__ import annotations from http import HTTPStatus -from uuid import UUID +from typing import Final +from uuid import UUID, uuid4 from flask_restx import Resource -from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequest, NotFound from constants.oauth_bearer import Scope from controllers.common.errors import ForbiddenError, NotFoundError -from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, Workspace +from controllers.common.rbac import RBACPermission from controllers.openapi import openapi_ns from controllers.openapi._contract import Example, Kind, endpoint, op_of +from controllers.openapi._errors import DraftChanged, DraftNotFound, DslInvalid, ErrorDetail, VersionNotFound from controllers.openapi._models import ( AppDslExportQuery, AppDslExportResponse, AppDslImportPayload, AppDslImportResponse, + CheckDependenciesResponse, + DslCheckPayload, + DslCheckResponse, + DslIssueRow, Hint, ) -from controllers.openapi.auth.requirements import ( - CheckAppApiEnabled, - CheckRBACPermission, - CheckScope, - CheckSubject, - CheckWorkspaceMember, - CheckWorkspaceRole, -) -from controllers.openapi.auth.subjects import AccountSubject +from controllers.openapi.auth.requirements import EDITOR_ROLES, account_app_guards, workspace_write_guards +from controllers.openapi.plugins import PluginInstallApi +from core.plugin.entities.plugin import PluginDependencyType from extensions.ext_application_services import application_services from machinery.context import AppRequestContext, RequestContext -from models.account import TenantAccountRole +from services.app.console_service import ConsoleAppNotFoundError from services.entities.app_entities import AppExportOptions -from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus -from services.errors.app import AppDiscoveryNotFoundError, IsDraftWorkflowError, WorkflowNotFoundError +from services.entities.dsl_entities import DraftGuardedImportParams, Import, ImportMode, ImportStatus +from services.errors.app import ( + AppDiscoveryNotFoundError, + DraftChangedError, + IsDraftWorkflowError, + WorkflowNotFoundError, +) from services.errors.base import NoPermissionError +from services.workflow.dsl_import import ( + DslNotCheckableError, + DslRefusedError, + YamlUrlFetchError, +) +from services.workflow.graph_check import IssueSeverity +from services.workflow.graph_diff import current_draft_token + +_DSL_READ_GUARDS: Final = account_app_guards( + RBACPermission.APP_IMPORT_EXPORT_DSL, scope=Scope.APPS_READ, roles=EDITOR_ROLES +) +_DSL_WRITE_GUARDS: Final = workspace_write_guards(RBACPermission.APP_IMPORT_EXPORT_DSL, roles=EDITOR_ROLES) + + +def _check_hint(context: RequestContext, app_id: str | None) -> Hint: + return Hint( + summary="See every problem, including warnings", + op=op_of(AppDslCheckApi.post), + input={"workspace_id": context.active_workspace_id, "yaml_content": None, "app_id": app_id}, + ) + + +def _prepared_yaml(context: RequestContext, body: AppDslImportPayload) -> str: + try: + return application_services().apps.dsl_imports.prepare( + context, + mode=ImportMode(body.mode), + source=body.source, + app_id=body.app_id, + draft_hash=body.draft_hash, + ) + except ConsoleAppNotFoundError as error: + raise NotFound(str(error)) from error + except DraftChangedError as error: + raise DraftChanged() from error + except DslRefusedError as error: + raise DslInvalid( + details=[ErrorDetail(type=i.code, loc=list(i.loc), msg=i.message) for i in error.issues], + hints=[_check_hint(context, body.app_id)], + ) from error def _import_response(result: Import, *, workspace_id: str) -> AppDslImportResponse: @@ -60,7 +105,9 @@ class AppDslImportApi(Resource): Returns 202 when the DSL version requires an explicit confirmation step (major version mismatch). Callers must then POST to the imports :confirm method. - Returns 400 when the import failed due to invalid DSL or a business error. + Returns 400 with the ``Import`` body when the import failed (an unreachable URL, + unparsable DSL or a business error), 409 ``draft_changed`` when ``draft_hash`` no + longer matches the draft, and 422 ``dsl_invalid`` when the graph has errors. """ @endpoint( @@ -82,13 +129,7 @@ class AppDslImportApi(Resource): input={"mode": "yaml-content", "yaml_content": "", "app_id": ""}, ), ), - requirements=( - CheckSubject(allowed=(AccountSubject,)), - CheckScope(Scope.WORKSPACE_WRITE), - CheckWorkspaceMember(), - CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())), - CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})), - ), + requirements=_DSL_WRITE_GUARDS, body=AppDslImportPayload, returns=( (HTTPStatus.OK, AppDslImportResponse, "Import completed"), @@ -98,11 +139,19 @@ class AppDslImportApi(Resource): ) def post(self, ctx: RequestContext, workspace_id: str, *, body: AppDslImportPayload): try: - result = application_services().apps.imports.import_app( - ctx, AppImportParams.model_validate(body.model_dump()) - ) + yaml_content = _prepared_yaml(ctx, body) + except YamlUrlFetchError as error: + failed = Import(id=str(uuid4()), status=ImportStatus.FAILED, error=str(error)) + return _import_response(failed, workspace_id=workspace_id), HTTPStatus.BAD_REQUEST + params = DraftGuardedImportParams.model_validate( + body.model_dump() | {"mode": ImportMode.YAML_CONTENT, "yaml_content": yaml_content, "yaml_url": None} + ) + try: + result = application_services().apps.imports.import_app(ctx, params) except NoPermissionError as exc: raise ForbiddenError(str(exc)) from exc + except DraftChangedError as exc: + raise DraftChanged() from exc response = _import_response(result, workspace_id=workspace_id) match result.status: @@ -114,6 +163,38 @@ class AppDslImportApi(Resource): return response, HTTPStatus.OK +@openapi_ns.route("/workspaces//apps/imports:check") +class AppDslCheckApi(Resource): + """Check a DSL without importing it. + + Errors are what the import refuses; warnings (missing credentials or models) are what the release check refuses. + Pass ``app_id`` to check against an existing app's mode, as an overwrite import would. + """ + + @endpoint( + context="workspace", + op="check.console_app.dsl", + kind=Kind.OBJECT, + summary="Check a DSL without importing it: every problem with its node and field", + examples=(Example(title="Check a DSL (pipe the YAML)", input={"yaml_content": ""}),), + requirements=_DSL_WRITE_GUARDS, + body=DslCheckPayload, + returns=(HTTPStatus.OK, DslCheckResponse, "Check result"), + ) + def post(self, ctx: RequestContext, workspace_id: str, *, body: DslCheckPayload): + try: + issues = application_services().apps.dsl_imports.check(ctx, body.yaml_content, body.app_id) + except DslNotCheckableError as error: + raise BadRequest(str(error)) from error + except ConsoleAppNotFoundError as error: + raise NotFound(str(error)) from error + response = DslCheckResponse( + valid=not any(i.code.severity is IssueSeverity.ERROR for i in issues), + issues=[DslIssueRow.of(i) for i in issues], + ) + return response, HTTPStatus.OK + + @openapi_ns.route("/workspaces//apps/imports/:confirm") class AppDslImportConfirmApi(Resource): """Confirm a pending DSL import identified by ``import_id``. @@ -123,7 +204,8 @@ class AppDslImportConfirmApi(Resource): stored in Redis for 10 minutes; this call retrieves it and completes the import under the given workspace. - Returns 400 when the pending data has expired or the import fails. + Returns 400 when the pending data has expired or the import fails, and 409 ``draft_changed`` + when the import carried a ``draft_hash`` the draft no longer matches. """ @endpoint( @@ -132,13 +214,7 @@ class AppDslImportConfirmApi(Resource): kind=Kind.OBJECT, summary="Confirm a pending DSL import", examples=(Example(title="Confirm an import that is pending confirmation", input={"import_id": ""}),), - requirements=( - CheckSubject(allowed=(AccountSubject,)), - CheckScope(Scope.WORKSPACE_WRITE), - CheckWorkspaceMember(), - CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())), - CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})), - ), + requirements=_DSL_WRITE_GUARDS, returns=((HTTPStatus.OK, Import, "Import confirmed"), (HTTPStatus.BAD_REQUEST, Import, "Import failed")), ) def post(self, ctx: RequestContext, workspace_id: str, import_id: str): @@ -146,6 +222,8 @@ class AppDslImportConfirmApi(Resource): result = application_services().apps.imports.confirm_import(ctx, import_id) except NoPermissionError as exc: raise ForbiddenError(str(exc)) from exc + except DraftChangedError as exc: + raise DraftChanged() from exc if result.status == ImportStatus.FAILED: return result, HTTPStatus.BAD_REQUEST @@ -157,8 +235,8 @@ class AppDslExportApi(Resource): """Export an app's current draft configuration as a DSL YAML string. The auth pipeline resolves the app and its tenant from ``app_id``. Pass - ``include_secret=true`` to embed encrypted credential values (e.g. tool - node secrets); omit it to produce a portable, sharable DSL safe to share. + ``include_secret=true`` to embed secret environment variable values in + plaintext and keep tool credential ids; omit it to produce a DSL safe to share. Note: the pipeline enforces ``app.enable_api`` for all ``/apps/`` routes in the openapi group. Apps with the service API disabled will @@ -177,27 +255,24 @@ class AppDslExportApi(Resource): input={"app_id": "", "workflow_id": "", "include_secret": True}, ), ), - requirements=( - CheckSubject(allowed=(AccountSubject,)), - CheckAppApiEnabled(), - CheckWorkspaceMember(), - CheckScope(Scope.APPS_READ), - CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp())), - CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})), - ), + requirements=_DSL_READ_GUARDS, query=AppDslExportQuery, returns=(200, AppDslExportResponse, "Export successful"), ) def get(self, ctx: AppRequestContext, app_id: str, *, query: AppDslExportQuery): + # Read the hash first: a draft edited in between then refuses the import instead of losing the edit. + draft_hash = current_draft_token(ctx.tenant_id, ctx.app_id) try: data = application_services().apps.exports.export_app( ctx, AppExportOptions(include_secret=query.include_secret, workflow_id=query.workflow_id) ) - except (WorkflowNotFoundError, AppDiscoveryNotFoundError) as exc: + except AppDiscoveryNotFoundError as exc: raise NotFoundError(str(exc)) from exc + except WorkflowNotFoundError as exc: + raise (VersionNotFound() if query.workflow_id else DraftNotFound()) from exc except IsDraftWorkflowError as exc: - raise BadRequest(str(exc)) from exc - return AppDslExportResponse(data=data), 200 + raise VersionNotFound() from exc + return AppDslExportResponse(data=data, draft_hash=draft_hash), 200 @openapi_ns.route("/apps//dependencies:check") @@ -216,17 +291,25 @@ class AppDslCheckDependenciesApi(Resource): kind=Kind.OBJECT, summary="Check plugin dependencies of an app", examples=(Example(title="Check which plugins an app needs", input={"app_id": ""}),), - requirements=( - CheckSubject(allowed=(AccountSubject,)), - CheckAppApiEnabled(), - CheckWorkspaceMember(), - CheckScope(Scope.APPS_READ), - CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp())), - CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})), - ), - returns=(HTTPStatus.OK, CheckDependenciesResult, "Dependencies checked"), + requirements=_DSL_READ_GUARDS, + returns=(HTTPStatus.OK, CheckDependenciesResponse, "Dependencies checked"), ) def get(self, ctx: RequestContext, app_id: str): result = application_services().apps.imports.check_dependencies(ctx, str(UUID(app_id))) - - return result, HTTPStatus.OK + missing = [ + dependency.value.plugin_unique_identifier + for dependency in result.leaked_dependencies + if dependency.type == PluginDependencyType.Marketplace + ] + hints = ( + [ + Hint( + summary="Install the missing marketplace plugins", + op=op_of(PluginInstallApi.post), + input={"workspace_id": ctx.active_workspace_id, "identifiers": missing}, + ) + ] + if missing + else [] + ) + return CheckDependenciesResponse(**result.model_dump(), hints=hints), HTTPStatus.OK diff --git a/api/controllers/openapi/app_info.py b/api/controllers/openapi/app_info.py new file mode 100644 index 00000000000..be3d0273200 --- /dev/null +++ b/api/controllers/openapi/app_info.py @@ -0,0 +1,277 @@ +"""App-info and Service API cards, one route per app mode.""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import Final + +from flask_restx import Resource + +from constants.oauth_bearer import Scope +from controllers.common.rbac import AgentBehindApp, PlainApp, RBACCheck, RBACPermission +from controllers.openapi import _app_settings as settings +from controllers.openapi import openapi_ns +from controllers.openapi._contract import Example, Kind, endpoint +from controllers.openapi._models import ( + AgentAppInfo, + AgentAppInfoPatch, + AgentServiceApi, + AppInfoPatch, + AppSettingsInfo, + ChatAppInfo, + ChatAppInfoPatch, + ServiceApi, + ServiceApiPatch, +) +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ADMIN_ROLES, EDITOR_ROLES, account_settings_guards +from models import AppMode + +_INFO_READ: Final = RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()) +_INFO_WRITE: Final = RBACCheck(RBACPermission.APP_EDIT, PlainApp()) +_AGENT_INFO_READ: Final = RBACCheck(RBACPermission.AGENT_PREVIEW, AgentBehindApp()) +_AGENT_INFO_WRITE: Final = RBACCheck(RBACPermission.AGENT_EDIT, AgentBehindApp()) +_API: Final = RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp()) +_AGENT_API_READ: Final = RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp()) +_AGENT_API_WRITE: Final = RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp()) + +_INFO_EXAMPLE: Final = Example(title="Read the app info", input={"app_id": ""}) +_RENAME_EXAMPLE: Final = Example(title="Rename the app", input={"app_id": "", "name": "Release notes"}) +_API_EXAMPLE: Final = Example(title="Read the Service API state", input={"app_id": ""}) +_API_ON_EXAMPLE: Final = Example(title="Turn the Service API on", input={"app_id": "", "enabled": True}) + + +@openapi_ns.route("/apps//app-info/workflow") +class WorkflowAppInfoApi(Resource): + @endpoint( + op="describe.app_info.workflow", + kind=Kind.OBJECT, + summary="Name, description, icon and run cap of a workflow app", + examples=(_INFO_EXAMPLE,), + requirements=account_settings_guards(_INFO_READ, scope=Scope.APPS_READ, modes=(AppMode.WORKFLOW,), roles=None), + returns=(HTTPStatus.OK, AppSettingsInfo, "App info"), + ) + def get(self, ctx: Context, app_id: str): + return settings.app_info(ctx, AppSettingsInfo) + + @endpoint( + op="set.app_info.workflow", + kind=Kind.OBJECT, + summary="Change a workflow app's info; only the fields passed change", + examples=(_RENAME_EXAMPLE,), + requirements=account_settings_guards( + _INFO_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.WORKFLOW,), roles=EDITOR_ROLES + ), + body=AppInfoPatch, + returns=(HTTPStatus.OK, AppSettingsInfo, "App info"), + ) + def patch(self, ctx: Context, app_id: str, *, body: AppInfoPatch): + return settings.update_app_info(ctx, body, AppSettingsInfo) + + +@openapi_ns.route("/apps//app-info/advanced-chat") +class AdvancedChatAppInfoApi(Resource): + @endpoint( + op="describe.app_info.advanced_chat", + kind=Kind.OBJECT, + summary="Name, description, icon and run cap of an advanced-chat app", + examples=(_INFO_EXAMPLE,), + requirements=account_settings_guards( + _INFO_READ, scope=Scope.APPS_READ, modes=(AppMode.ADVANCED_CHAT,), roles=None + ), + returns=(HTTPStatus.OK, ChatAppInfo, "App info"), + ) + def get(self, ctx: Context, app_id: str): + return settings.app_info(ctx, ChatAppInfo) + + @endpoint( + op="set.app_info.advanced_chat", + kind=Kind.OBJECT, + summary="Change an advanced-chat app's info; only the fields passed change", + examples=(_RENAME_EXAMPLE,), + requirements=account_settings_guards( + _INFO_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.ADVANCED_CHAT,), roles=EDITOR_ROLES + ), + body=ChatAppInfoPatch, + returns=(HTTPStatus.OK, ChatAppInfo, "App info"), + ) + def patch(self, ctx: Context, app_id: str, *, body: ChatAppInfoPatch): + return settings.update_app_info(ctx, body, ChatAppInfo) + + +@openapi_ns.route("/apps//app-info/chat") +class ChatAppInfoApi(Resource): + @endpoint( + op="describe.app_info.chat", + kind=Kind.OBJECT, + summary="Name, description, icon and run cap of a chat app", + examples=(_INFO_EXAMPLE,), + requirements=account_settings_guards(_INFO_READ, scope=Scope.APPS_READ, modes=(AppMode.CHAT,), roles=None), + returns=(HTTPStatus.OK, ChatAppInfo, "App info"), + ) + def get(self, ctx: Context, app_id: str): + return settings.app_info(ctx, ChatAppInfo) + + @endpoint( + op="set.app_info.chat", + kind=Kind.OBJECT, + summary="Change a chat app's info; only the fields passed change", + examples=(_RENAME_EXAMPLE,), + requirements=account_settings_guards( + _INFO_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.CHAT,), roles=EDITOR_ROLES + ), + body=ChatAppInfoPatch, + returns=(HTTPStatus.OK, ChatAppInfo, "App info"), + ) + def patch(self, ctx: Context, app_id: str, *, body: ChatAppInfoPatch): + return settings.update_app_info(ctx, body, ChatAppInfo) + + +@openapi_ns.route("/apps//app-info/agent-chat") +class AgentChatAppInfoApi(Resource): + @endpoint( + op="describe.app_info.agent_chat", + kind=Kind.OBJECT, + summary="Name, description, icon and run cap of an agent-chat app", + examples=(_INFO_EXAMPLE,), + requirements=account_settings_guards( + _INFO_READ, scope=Scope.APPS_READ, modes=(AppMode.AGENT_CHAT,), roles=None + ), + returns=(HTTPStatus.OK, ChatAppInfo, "App info"), + ) + def get(self, ctx: Context, app_id: str): + return settings.app_info(ctx, ChatAppInfo) + + @endpoint( + op="set.app_info.agent_chat", + kind=Kind.OBJECT, + summary="Change an agent-chat app's info; only the fields passed change", + examples=(_RENAME_EXAMPLE,), + requirements=account_settings_guards( + _INFO_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.AGENT_CHAT,), roles=EDITOR_ROLES + ), + body=ChatAppInfoPatch, + returns=(HTTPStatus.OK, ChatAppInfo, "App info"), + ) + def patch(self, ctx: Context, app_id: str, *, body: ChatAppInfoPatch): + return settings.update_app_info(ctx, body, ChatAppInfo) + + +@openapi_ns.route("/apps//app-info/completion") +class CompletionAppInfoApi(Resource): + @endpoint( + op="describe.app_info.completion", + kind=Kind.OBJECT, + summary="Name, description, icon and run cap of a completion app", + examples=(_INFO_EXAMPLE,), + requirements=account_settings_guards( + _INFO_READ, scope=Scope.APPS_READ, modes=(AppMode.COMPLETION,), roles=None + ), + returns=(HTTPStatus.OK, AppSettingsInfo, "App info"), + ) + def get(self, ctx: Context, app_id: str): + return settings.app_info(ctx, AppSettingsInfo) + + @endpoint( + op="set.app_info.completion", + kind=Kind.OBJECT, + summary="Change a completion app's info; only the fields passed change", + examples=(_RENAME_EXAMPLE,), + requirements=account_settings_guards( + _INFO_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.COMPLETION,), roles=EDITOR_ROLES + ), + body=AppInfoPatch, + returns=(HTTPStatus.OK, AppSettingsInfo, "App info"), + ) + def patch(self, ctx: Context, app_id: str, *, body: AppInfoPatch): + return settings.update_app_info(ctx, body, AppSettingsInfo) + + +@openapi_ns.route("/apps//app-info/agent") +class AgentAppInfoApi(Resource): + @endpoint( + op="describe.app_info.agent", + kind=Kind.OBJECT, + summary="Name, description, icon, role and run cap of an agent app", + examples=(_INFO_EXAMPLE,), + requirements=account_settings_guards( + _AGENT_INFO_READ, scope=Scope.APPS_READ, modes=(AppMode.AGENT,), roles=None + ), + returns=(HTTPStatus.OK, AgentAppInfo, "App info"), + ) + def get(self, ctx: Context, app_id: str): + return settings.agent_app_info(ctx) + + @endpoint( + op="set.app_info.agent", + kind=Kind.OBJECT, + summary="Change an agent app's info; only the fields passed change", + examples=(_RENAME_EXAMPLE,), + requirements=account_settings_guards( + _AGENT_INFO_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.AGENT,), roles=EDITOR_ROLES + ), + body=AgentAppInfoPatch, + returns=(HTTPStatus.OK, AgentAppInfo, "App info"), + ) + def patch(self, ctx: Context, app_id: str, *, body: AgentAppInfoPatch): + return settings.update_agent_app_info(ctx, body) + + +@openapi_ns.route("/apps//service-api") +class ServiceApiApi(Resource): + @endpoint( + op="describe.service_api", + kind=Kind.OBJECT, + summary="Whether an app's Service API is on, and its base URL (agent apps: describe.service_api.agent)", + examples=(_API_EXAMPLE,), + requirements=account_settings_guards( + _API, scope=Scope.APPS_READ, modes=settings.REGULAR_MODES, roles=EDITOR_ROLES + ), + returns=(HTTPStatus.OK, ServiceApi, "Service API"), + ) + def get(self, ctx: Context, app_id: str): + return settings.service_api(ctx, ServiceApi) + + @endpoint( + op="set.service_api", + kind=Kind.OBJECT, + summary="Turn an app's Service API on or off (agent apps: set.service_api.agent)", + examples=(_API_ON_EXAMPLE,), + requirements=account_settings_guards( + _API, scope=Scope.WORKSPACE_WRITE, modes=settings.REGULAR_MODES, roles=ADMIN_ROLES + ), + body=ServiceApiPatch, + returns=(HTTPStatus.OK, ServiceApi, "Service API"), + ) + def patch(self, ctx: Context, app_id: str, *, body: ServiceApiPatch): + return settings.update_service_api(ctx, body.enabled, ServiceApi) + + +@openapi_ns.route("/apps//service-api/agent") +class AgentServiceApiApi(Resource): + @endpoint( + op="describe.service_api.agent", + kind=Kind.OBJECT, + summary="Whether an agent app's Service API is on, and its base URL", + examples=(_API_EXAMPLE,), + requirements=account_settings_guards( + _AGENT_API_READ, scope=Scope.APPS_READ, modes=(AppMode.AGENT,), roles=EDITOR_ROLES + ), + returns=(HTTPStatus.OK, AgentServiceApi, "Service API"), + ) + def get(self, ctx: Context, app_id: str): + return settings.agent_service_api(ctx) + + @endpoint( + op="set.service_api.agent", + kind=Kind.OBJECT, + summary="Turn an agent app's Service API on or off", + examples=(_API_ON_EXAMPLE,), + requirements=account_settings_guards( + _AGENT_API_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.AGENT,), roles=ADMIN_ROLES + ), + body=ServiceApiPatch, + returns=(HTTPStatus.OK, AgentServiceApi, "Service API"), + ) + def patch(self, ctx: Context, app_id: str, *, body: ServiceApiPatch): + return settings.update_agent_service_api(ctx, body.enabled) diff --git a/api/controllers/openapi/app_release.py b/api/controllers/openapi/app_release.py new file mode 100644 index 00000000000..c04f0f15fcc --- /dev/null +++ b/api/controllers/openapi/app_release.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from http import HTTPStatus + +from flask_restx import Resource + +from controllers.openapi import openapi_ns +from controllers.openapi._contract import Example, Kind, endpoint, op_of +from controllers.openapi._models import ( + DslIssueRow, + Hint, + ReleaseCheckName, + ReleaseCheckResponse, + ReleaseCheckRow, + ReleaseState, +) +from controllers.openapi.app_dsl import AppDslExportApi +from controllers.openapi.app_run import draft_test_hint +from controllers.openapi.app_workflow import VERSION_READ_GUARDS, require_draft +from controllers.openapi.auth.context import Context +from extensions.ext_application_services import application_services +from graphon.enums import WorkflowExecutionStatus +from models import AppMode, WorkflowRunTriggeredFrom +from models.workflow import Workflow, WorkflowRun +from services.workflow.graph_check import check_graph, credential_check +from services.workflow.graph_diff import WorkflowSnapshot, diff_workflows, same_graph +from services.workflow_service import WorkflowService + + +def _snapshot(workflow: Workflow) -> WorkflowSnapshot: + return WorkflowSnapshot( + graph=workflow.graph_dict, + features=workflow.features_dict, + environment_variable_names=frozenset(v.name for v in workflow.environment_variables), + ) + + +def passed_on_draft(run: WorkflowRun | None, draft: Workflow) -> bool: + """The run passed on this draft. A run keeps only its graph, so a draft saved after the run started + (environment variables, conversation variables, features) counts as untested.""" + return ( + run is not None + and run.status == WorkflowExecutionStatus.SUCCEEDED + and same_graph(run.graph_dict, draft.graph_dict) + and draft.updated_at <= run.created_at + ) + + +@openapi_ns.route("/apps//release:check") +class ReleaseCheckApi(Resource): + @endpoint( + op="check.console_app.release", + kind=Kind.OBJECT, + summary="Is the draft ready to publish: draft valid, last test passed on this draft, and what changed", + examples=(Example(title="Before publishing", input={"app_id": ""}),), + requirements=VERSION_READ_GUARDS, + returns=(HTTPStatus.OK, ReleaseCheckResponse, "Release check"), + ) + def get(self, ctx: Context, app_id: str): + draft = require_draft(ctx) + mode = AppMode(ctx.app.mode) + service = WorkflowService() + environment = {v.name: v for v in draft.environment_variables} + issues = check_graph( + draft.graph_dict, + mode=mode, + resources=credential_check(ctx.workspace.id, environment, ctx.session), + ) + runs = ( + application_services() + .workflow_runs.get_paginate_workflow_runs( + ctx.request_context, + app_id=ctx.app.id, + args={"limit": 1}, + triggered_from=WorkflowRunTriggeredFrom.DEBUGGING, + ) + .data + ) + tested = passed_on_draft(runs[0] if runs else None, draft) + published = service.get_published_workflow(ctx.app, session=ctx.session) + diff = diff_workflows(_snapshot(published) if published else None, _snapshot(draft)) + checks = [ + ReleaseCheckRow( + name=ReleaseCheckName.DRAFT_VALID, + passed=not issues, + detail=f"{len(issues)} issue(s)" if issues else "No issues", + ), + ReleaseCheckRow( + name=ReleaseCheckName.TESTED, + passed=tested, + detail="The last draft test passed on this draft" + if tested + else "No passing draft test on the current draft", + ), + ] + hints = [] + if issues: + hints.append( + Hint( + summary="Export the draft, fix each issue by its loc, then check and import", + op=op_of(AppDslExportApi.get), + input={"app_id": ctx.app.id}, + ) + ) + if not tested: + hints.append(draft_test_hint(mode, app_id=ctx.app.id, summary="Run the draft on the acceptance cases")) + return ReleaseCheckResponse( + ready=all(c.passed for c in checks), + checks=checks, + issues=[DslIssueRow.of(i) for i in issues], + state=ReleaseState(service_api_enabled=ctx.app.enable_api, webapp_enabled=ctx.app.enable_site), + changes=diff, + hints=hints, + ) diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index 881242de377..01604ad5556 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -16,7 +16,6 @@ from werkzeug.exceptions import ( InternalServerError, NotFound, TooManyRequests, - UnprocessableEntity, ) import services @@ -26,12 +25,14 @@ from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission from controllers.openapi import openapi_ns from controllers.openapi._audit import emit_app_run from controllers.openapi._contract import Example, Kind, endpoint +from controllers.openapi._errors import AppModeMismatch from controllers.openapi._files import end_read_transaction, materialize, merge_files from controllers.openapi._hints import attach_stream_hints from controllers.openapi._models import ( AdvancedChatRunPayload, ChatRunPayload, CompletionRunPayload, + DraftWorkflowRunPayload, Hint, RunPayloadBase, TaskStopResponse, @@ -39,12 +40,15 @@ from controllers.openapi._models import ( ) from controllers.openapi.auth.context import Context from controllers.openapi.auth.requirements import ( + EDITOR_ROLES, CheckAppAccess, CheckAppApiEnabled, CheckRBACPermission, CheckScope, CheckSubject, CheckWorkspaceMember, + Requirement, + account_app_guards, ) from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject from controllers.openapi.human_input_form import with_form_hints @@ -140,16 +144,17 @@ _RUN_GUARDS: Final = ( CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), CheckAppAccess(), ) +_DRAFT_FORM_NOTE: Final = "A human-input pause in a draft run cannot be resumed over openapi." _STREAM_RESULT: Final = (200, EventStreamResponse, "Run result (SSE stream)") -def _generate(app: App, caller: Any, args: dict[str, Any], session: Session): +def _generate(app: App, caller: Any, args: dict[str, Any], session: Session, invoke_from: InvokeFrom): return AppGenerateService.generate( session=session, app_model=app, user=caller, args=args, - invoke_from=InvokeFrom.OPENAPI, + invoke_from=invoke_from, streaming=True, ) @@ -164,14 +169,15 @@ def _generate_args(ctx: Context, payload: RunPayloadBase, *, exclude: Collection return args -def _stream(ctx: Context, args: dict[str, Any]): +def _stream(ctx: Context, args: dict[str, Any], invoke_from: InvokeFrom): with _translate_service_errors(): - return _generate(ctx.app, ctx.caller, args, ctx.session) + return _generate(ctx.app, ctx.caller, args, ctx.session, invoke_from) def _require_mode(app: App, *modes: AppMode) -> None: + """In the handler, not a guard: an external caller the access check refuses must not learn the app's mode.""" if app.mode not in modes: - raise UnprocessableEntity("app_mode_mismatch") + raise AppModeMismatch() def _respond(ctx: Context, stream: Any): @@ -231,6 +237,20 @@ class _RunRoute: modes: tuple[AppMode, ...] hints: tuple[HintLayer, ...] = () examples: tuple[Example, ...] = () + invoke_from: InvokeFrom = InvokeFrom.OPENAPI + + @property + def draft(self) -> bool: + return self.invoke_from is InvokeFrom.DEBUGGER + + @property + def requirements(self) -> tuple[Requirement, ...]: + """Draft runs are account-only, so their guards check the mode before RBAC as other account routes do.""" + if self.draft: + return account_app_guards( + RBACPermission.APP_TEST_AND_RUN, scope=Scope.APPS_RUN, roles=EDITOR_ROLES, modes=self.modes + ) + return _RUN_GUARDS _RUN_ROUTES: Final = ( @@ -249,11 +269,15 @@ _RUN_ROUTES: Final = ( ), Example( title="Run a workflow with a local file variable", - input={"app_id": "", "inputs": {}, "files": {"document": "./report.pdf"}}, + input={"app_id": "", "files": {"document": "./report.pdf"}}, + ), + Example( + title="Run a workflow with a list of local files for a file-list variable", + input={"app_id": "", "files": {"pages": ["./page-1.png", "./page-2.png"]}}, ), Example( title="Run a pinned published workflow version", - input={"app_id": "", "inputs": {}, "workflow_id": ""}, + input={"app_id": "", "workflow_id": ""}, ), ), ), @@ -261,21 +285,20 @@ _RUN_ROUTES: Final = ( resource="ChatRunApi", segment="chat", op="run.console_app.chat", - summary="Run a chat or agent app; streams message events", + summary="Run a chat or agent-chat app; streams message events", payload=ChatRunPayload, modes=(AppMode.CHAT, AppMode.AGENT_CHAT), hints=(_reply_layer,), examples=( Example( title="Start a new conversation", - input={"app_id": "", "query": "Summarise the latest release", "inputs": {}}, + input={"app_id": "", "query": "Summarise the latest release"}, ), Example( title="Reply in an existing conversation", input={ "app_id": "", "query": "Make it shorter", - "inputs": {}, "conversation_id": "", }, ), @@ -284,10 +307,13 @@ _RUN_ROUTES: Final = ( input={ "app_id": "", "query": "What is in this file?", - "inputs": {}, "attachments": ["./report.pdf"], }, ), + Example( + title="Fill a file variable with a local file", + input={"app_id": "", "query": "Summarise the document", "files": {"document": "./report.pdf"}}, + ), ), ), _RunRoute( @@ -301,17 +327,20 @@ _RUN_ROUTES: Final = ( examples=( Example( title="Start a new conversation", - input={"app_id": "", "query": "Summarise the latest release", "inputs": {}}, + input={"app_id": "", "query": "Summarise the latest release"}, ), Example( title="Reply in an existing conversation", input={ "app_id": "", "query": "Make it shorter", - "inputs": {}, "conversation_id": "", }, ), + Example( + title="Fill a file variable with a local file", + input={"app_id": "", "query": "Summarise the document", "files": {"document": "./report.pdf"}}, + ), ), ), _RunRoute( @@ -328,26 +357,73 @@ _RUN_ROUTES: Final = ( ), Example( title="Run a completion app with prompt text", - input={"app_id": "", "inputs": {}, "query": "Write a haiku about the sea"}, + input={"app_id": "", "query": "Write a haiku about the sea"}, + ), + Example( + title="Fill a file variable with a local file", + input={"app_id": "", "files": {"document": "./report.pdf"}}, ), ), ), + _RunRoute( + resource="WorkflowDraftRunApi", + segment="draft/workflow", + op="test.console_app.workflow", + summary="Test-run the draft of a workflow app; streams workflow events. " + _DRAFT_FORM_NOTE, + payload=DraftWorkflowRunPayload, + modes=(AppMode.WORKFLOW,), + examples=( + Example(title="Test the draft with one input", input={"app_id": "", "inputs": {"topic": "test"}}), + Example( + title="Test the draft with a local file variable", + input={"app_id": "", "files": {"document": "./report.pdf"}}, + ), + ), + invoke_from=InvokeFrom.DEBUGGER, + ), + _RunRoute( + resource="AdvancedChatDraftRunApi", + segment="draft/advanced-chat", + op="test.console_app.advanced_chat", + summary="Test-run the draft of an advanced-chat app; streams message and workflow events. " + _DRAFT_FORM_NOTE, + payload=ChatRunPayload, + modes=(AppMode.ADVANCED_CHAT,), + hints=(_reply_layer,), + examples=( + Example(title="Test the draft with one message", input={"app_id": "", "query": "hello"}), + Example( + title="Test the draft with a local file variable", + input={"app_id": "", "query": "Summarise the document", "files": {"document": "./report.pdf"}}, + ), + ), + invoke_from=InvokeFrom.DEBUGGER, + ), ) +_DRAFT_TEST_ROUTES: Final = {mode: route for route in _RUN_ROUTES if route.draft for mode in route.modes} + + +def draft_test_hint(mode: AppMode, *, app_id: str, summary: str) -> Hint: + """Test-run the draft of an app in `mode`, every required field of that route's payload null.""" + route = _DRAFT_TEST_ROUTES[mode] + required = {name: None for name, field in route.payload.model_fields.items() if field.is_required()} + return Hint(summary=summary, op=route.op, input={"app_id": app_id} | required) + def _run_api(route: _RunRoute) -> type[Resource]: @endpoint( op=route.op, kind=Kind.SSE, summary=route.summary, - requirements=_RUN_GUARDS, + requirements=route.requirements, body=route.payload, examples=route.examples, returns=_STREAM_RESULT, ) def post(self: Resource, ctx: Context, app_id: str, *, body: RunPayloadBase): - _require_mode(ctx.app, *route.modes) - stream = _stream(ctx, _generate_args(ctx, body)) + if not route.draft: + _require_mode(ctx.app, *route.modes) + stream = _stream(ctx, _generate_args(ctx, body), route.invoke_from) for layer in route.hints: stream = layer(stream, route.op, ctx.app.id) return _respond(ctx, stream) @@ -356,7 +432,14 @@ def _run_api(route: _RunRoute) -> type[Resource]: return openapi_ns.route(f"/apps//{route.segment}:run")(resource) -WorkflowRunApi, ChatRunApi, AdvancedChatRunApi, CompletionRunApi = (_run_api(route) for route in _RUN_ROUTES) +( + WorkflowRunApi, + ChatRunApi, + AdvancedChatRunApi, + CompletionRunApi, + WorkflowDraftRunApi, + AdvancedChatDraftRunApi, +) = (_run_api(route) for route in _RUN_ROUTES) @openapi_ns.route("/apps//tasks/:stop") diff --git a/api/controllers/openapi/app_webapp.py b/api/controllers/openapi/app_webapp.py new file mode 100644 index 00000000000..597fa034a05 --- /dev/null +++ b/api/controllers/openapi/app_webapp.py @@ -0,0 +1,329 @@ +"""Web-app and web-app access cards: one route for regular apps, one for agent apps.""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import Final + +from flask_restx import Resource + +from constants.oauth_bearer import Scope +from controllers.common.rbac import AgentBehindApp, PlainApp, RBACCheck, RBACPermission +from controllers.openapi import _app_settings as settings +from controllers.openapi import openapi_ns +from controllers.openapi._contract import Example, Kind, endpoint +from controllers.openapi._models import ( + AdvancedChatWebApp, + AdvancedChatWebAppPatch, + AgentWebApp, + ChatWebApp, + ChatWebAppPatch, + WebApp, + WebAppAccess, + WebAppAccessPayload, + WebAppPatch, + WebAppToken, + WorkflowWebApp, + WorkflowWebAppPatch, +) +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ( + ADMIN_ROLES, + EDITOR_ROLES, + CheckWebAppAuthEnterprise, + account_settings_guards, +) +from models import AppMode + +_SITE_READ: Final = RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()) +_SITE_WRITE: Final = RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp()) +_AGENT_SITE_READ: Final = RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp()) +_AGENT_SITE_WRITE: Final = RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp()) +_ACCESS: Final = RBACCheck(RBACPermission.APP_ACCESS_CONFIG, PlainApp()) +_AGENT_ACCESS: Final = RBACCheck(RBACPermission.AGENT_ACCESS_CONFIG, AgentBehindApp()) + +_WEBAPP_EXAMPLE: Final = Example(title="Read the web app settings", input={"app_id": ""}) +_WEBAPP_ON_EXAMPLE: Final = Example( + title="Turn the web app on and set its title", input={"app_id": "", "enabled": True, "title": "Support"} +) +_RESET_EXAMPLE: Final = Example(title="Rotate the web app URL", input={"app_id": ""}) +_ACCESS_EXAMPLE: Final = Example(title="Read who may open the web app", input={"app_id": ""}) +_ACCESS_SET_EXAMPLE: Final = Example( + title="Let every member open the web app", input={"app_id": "", "access_mode": "private_all"} +) + + +@openapi_ns.route("/apps//webapp/workflow") +class WorkflowWebAppApi(Resource): + @endpoint( + op="describe.webapp.workflow", + kind=Kind.OBJECT, + summary="A workflow app's web app: on or off, its URL token and page settings", + examples=(_WEBAPP_EXAMPLE,), + requirements=account_settings_guards(_SITE_READ, scope=Scope.APPS_READ, modes=(AppMode.WORKFLOW,), roles=None), + returns=(HTTPStatus.OK, WorkflowWebApp, "Web app"), + ) + def get(self, ctx: Context, app_id: str): + return settings.webapp(ctx, WorkflowWebApp) + + @endpoint( + op="set.webapp.workflow", + kind=Kind.OBJECT, + summary="Change a workflow app's web app; only the fields passed change", + examples=(_WEBAPP_ON_EXAMPLE,), + requirements=account_settings_guards( + _SITE_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.WORKFLOW,), roles=EDITOR_ROLES + ), + body=WorkflowWebAppPatch, + returns=(HTTPStatus.OK, WorkflowWebApp, "Web app"), + ) + def patch(self, ctx: Context, app_id: str, *, body: WorkflowWebAppPatch): + return settings.update_webapp(ctx, body, WorkflowWebApp) + + +@openapi_ns.route("/apps//webapp/advanced-chat") +class AdvancedChatWebAppApi(Resource): + @endpoint( + op="describe.webapp.advanced_chat", + kind=Kind.OBJECT, + summary="An advanced-chat app's web app: on or off, its URL token and page settings", + examples=(_WEBAPP_EXAMPLE,), + requirements=account_settings_guards( + _SITE_READ, scope=Scope.APPS_READ, modes=(AppMode.ADVANCED_CHAT,), roles=None + ), + returns=(HTTPStatus.OK, AdvancedChatWebApp, "Web app"), + ) + def get(self, ctx: Context, app_id: str): + return settings.webapp(ctx, AdvancedChatWebApp) + + @endpoint( + op="set.webapp.advanced_chat", + kind=Kind.OBJECT, + summary="Change an advanced-chat app's web app; only the fields passed change", + examples=(_WEBAPP_ON_EXAMPLE,), + requirements=account_settings_guards( + _SITE_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.ADVANCED_CHAT,), roles=EDITOR_ROLES + ), + body=AdvancedChatWebAppPatch, + returns=(HTTPStatus.OK, AdvancedChatWebApp, "Web app"), + ) + def patch(self, ctx: Context, app_id: str, *, body: AdvancedChatWebAppPatch): + return settings.update_webapp(ctx, body, AdvancedChatWebApp) + + +@openapi_ns.route("/apps//webapp/chat") +class ChatWebAppApi(Resource): + @endpoint( + op="describe.webapp.chat", + kind=Kind.OBJECT, + summary="A chat app's web app: on or off, its URL token and page settings", + examples=(_WEBAPP_EXAMPLE,), + requirements=account_settings_guards(_SITE_READ, scope=Scope.APPS_READ, modes=(AppMode.CHAT,), roles=None), + returns=(HTTPStatus.OK, ChatWebApp, "Web app"), + ) + def get(self, ctx: Context, app_id: str): + return settings.webapp(ctx, ChatWebApp) + + @endpoint( + op="set.webapp.chat", + kind=Kind.OBJECT, + summary="Change a chat app's web app; only the fields passed change", + examples=(_WEBAPP_ON_EXAMPLE,), + requirements=account_settings_guards( + _SITE_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.CHAT,), roles=EDITOR_ROLES + ), + body=ChatWebAppPatch, + returns=(HTTPStatus.OK, ChatWebApp, "Web app"), + ) + def patch(self, ctx: Context, app_id: str, *, body: ChatWebAppPatch): + return settings.update_webapp(ctx, body, ChatWebApp) + + +@openapi_ns.route("/apps//webapp/agent-chat") +class AgentChatWebAppApi(Resource): + @endpoint( + op="describe.webapp.agent_chat", + kind=Kind.OBJECT, + summary="An agent-chat app's web app: on or off, its URL token and page settings", + examples=(_WEBAPP_EXAMPLE,), + requirements=account_settings_guards( + _SITE_READ, scope=Scope.APPS_READ, modes=(AppMode.AGENT_CHAT,), roles=None + ), + returns=(HTTPStatus.OK, ChatWebApp, "Web app"), + ) + def get(self, ctx: Context, app_id: str): + return settings.webapp(ctx, ChatWebApp) + + @endpoint( + op="set.webapp.agent_chat", + kind=Kind.OBJECT, + summary="Change an agent-chat app's web app; only the fields passed change", + examples=(_WEBAPP_ON_EXAMPLE,), + requirements=account_settings_guards( + _SITE_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.AGENT_CHAT,), roles=EDITOR_ROLES + ), + body=ChatWebAppPatch, + returns=(HTTPStatus.OK, ChatWebApp, "Web app"), + ) + def patch(self, ctx: Context, app_id: str, *, body: ChatWebAppPatch): + return settings.update_webapp(ctx, body, ChatWebApp) + + +@openapi_ns.route("/apps//webapp/completion") +class CompletionWebAppApi(Resource): + @endpoint( + op="describe.webapp.completion", + kind=Kind.OBJECT, + summary="A completion app's web app: on or off, its URL token and page settings", + examples=(_WEBAPP_EXAMPLE,), + requirements=account_settings_guards( + _SITE_READ, scope=Scope.APPS_READ, modes=(AppMode.COMPLETION,), roles=None + ), + returns=(HTTPStatus.OK, WebApp, "Web app"), + ) + def get(self, ctx: Context, app_id: str): + return settings.webapp(ctx, WebApp) + + @endpoint( + op="set.webapp.completion", + kind=Kind.OBJECT, + summary="Change a completion app's web app; only the fields passed change", + examples=(_WEBAPP_ON_EXAMPLE,), + requirements=account_settings_guards( + _SITE_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.COMPLETION,), roles=EDITOR_ROLES + ), + body=WebAppPatch, + returns=(HTTPStatus.OK, WebApp, "Web app"), + ) + def patch(self, ctx: Context, app_id: str, *, body: WebAppPatch): + return settings.update_webapp(ctx, body, WebApp) + + +@openapi_ns.route("/apps//webapp/agent") +class AgentWebAppApi(Resource): + @endpoint( + op="describe.webapp.agent", + kind=Kind.OBJECT, + summary="An agent app's web app: on or off, its URL token and page settings", + examples=(_WEBAPP_EXAMPLE,), + requirements=account_settings_guards( + _AGENT_SITE_READ, scope=Scope.APPS_READ, modes=(AppMode.AGENT,), roles=None + ), + returns=(HTTPStatus.OK, AgentWebApp, "Web app"), + ) + def get(self, ctx: Context, app_id: str): + return settings.agent_webapp(ctx) + + @endpoint( + op="set.webapp.agent", + kind=Kind.OBJECT, + summary="Change an agent app's web app; only the fields passed change", + examples=(_WEBAPP_ON_EXAMPLE,), + requirements=account_settings_guards( + _AGENT_SITE_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.AGENT,), roles=EDITOR_ROLES + ), + body=ChatWebAppPatch, + returns=(HTTPStatus.OK, AgentWebApp, "Web app"), + ) + def patch(self, ctx: Context, app_id: str, *, body: ChatWebAppPatch): + return settings.update_agent_webapp(ctx, body) + + +@openapi_ns.route("/apps//webapp/agent:reset") +class AgentWebAppResetApi(Resource): + @endpoint( + op="reset.webapp.agent", + kind=Kind.OBJECT, + summary="Rotate an agent app's web app URL; the old URL stops working", + examples=(_RESET_EXAMPLE,), + requirements=account_settings_guards( + _AGENT_SITE_WRITE, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.AGENT,), roles=ADMIN_ROLES + ), + returns=(HTTPStatus.OK, WebAppToken, "New URL token"), + ) + def post(self, ctx: Context, app_id: str): + return settings.reset_webapp(ctx) + + +@openapi_ns.route("/apps//webapp-access/agent") +class AgentWebAppAccessApi(Resource): + @endpoint( + op="describe.webapp_access.agent", + kind=Kind.OBJECT, + summary="Who may open an agent app's web app (Enterprise)", + examples=(_ACCESS_EXAMPLE,), + requirements=( + *account_settings_guards(_AGENT_ACCESS, scope=Scope.APPS_READ, modes=(AppMode.AGENT,), roles=EDITOR_ROLES), + CheckWebAppAuthEnterprise(), + ), + returns=(HTTPStatus.OK, WebAppAccess, "Web-app access"), + ) + def get(self, ctx: Context, app_id: str): + return settings.webapp_access(ctx) + + @endpoint( + op="set.webapp_access.agent", + kind=Kind.OBJECT, + summary="Set who may open an agent app's web app (Enterprise)", + examples=(_ACCESS_SET_EXAMPLE,), + requirements=( + *account_settings_guards( + _AGENT_ACCESS, scope=Scope.WORKSPACE_WRITE, modes=(AppMode.AGENT,), roles=EDITOR_ROLES + ), + CheckWebAppAuthEnterprise(), + ), + body=WebAppAccessPayload, + returns=(HTTPStatus.OK, WebAppAccess, "Web-app access"), + ) + def put(self, ctx: Context, app_id: str, *, body: WebAppAccessPayload): + return settings.update_webapp_access(ctx, body) + + +@openapi_ns.route("/apps//webapp:reset") +class WebAppResetApi(Resource): + @endpoint( + op="reset.webapp", + kind=Kind.OBJECT, + summary="Rotate an app's web app URL; the old URL stops working (agent apps: reset.webapp.agent)", + examples=(_RESET_EXAMPLE,), + requirements=account_settings_guards( + _SITE_WRITE, scope=Scope.WORKSPACE_WRITE, modes=settings.REGULAR_MODES, roles=ADMIN_ROLES + ), + returns=(HTTPStatus.OK, WebAppToken, "New URL token"), + ) + def post(self, ctx: Context, app_id: str): + return settings.reset_webapp(ctx) + + +@openapi_ns.route("/apps//webapp-access") +class WebAppAccessApi(Resource): + @endpoint( + op="describe.webapp_access", + kind=Kind.OBJECT, + summary="Who may open an app's web app (Enterprise; agent apps: describe.webapp_access.agent)", + examples=(_ACCESS_EXAMPLE,), + requirements=( + *account_settings_guards(_ACCESS, scope=Scope.APPS_READ, modes=settings.REGULAR_MODES, roles=EDITOR_ROLES), + CheckWebAppAuthEnterprise(), + ), + returns=(HTTPStatus.OK, WebAppAccess, "Web-app access"), + ) + def get(self, ctx: Context, app_id: str): + return settings.webapp_access(ctx) + + @endpoint( + op="set.webapp_access", + kind=Kind.OBJECT, + summary="Set who may open an app's web app (Enterprise; agent apps: set.webapp_access.agent)", + examples=(_ACCESS_SET_EXAMPLE,), + requirements=( + *account_settings_guards( + _ACCESS, scope=Scope.WORKSPACE_WRITE, modes=settings.REGULAR_MODES, roles=EDITOR_ROLES + ), + CheckWebAppAuthEnterprise(), + ), + body=WebAppAccessPayload, + returns=(HTTPStatus.OK, WebAppAccess, "Web-app access"), + ) + def put(self, ctx: Context, app_id: str, *, body: WebAppAccessPayload): + return settings.update_webapp_access(ctx, body) diff --git a/api/controllers/openapi/app_workflow.py b/api/controllers/openapi/app_workflow.py new file mode 100644 index 00000000000..2a6ebe0ef48 --- /dev/null +++ b/api/controllers/openapi/app_workflow.py @@ -0,0 +1,466 @@ +"""Build routes on /openapi/v1 for workflow and advanced-chat apps: run history, +publishing and versions, and draft environment variables.""" + +from __future__ import annotations + +from collections.abc import Iterable +from http import HTTPStatus +from typing import Any, Final +from uuid import UUID + +from flask_restx import Resource +from werkzeug.exceptions import BadRequest + +from constants import HIDDEN_VALUE +from constants.oauth_bearer import Scope +from controllers.common.fields import SimpleResultResponse +from controllers.common.rbac import RBACPermission +from controllers.openapi import openapi_ns +from controllers.openapi._contract import Example, Kind, endpoint, op_of +from controllers.openapi._errors import ( + DraftNotFound, + EnvVariableNotFound, + NodeNotFound, + OpenApiError, + RunNotFound, + SecretMaskNotSecret, + SecretMaskUnknownId, + VersionNotFound, + VersionNotRestorable, +) +from controllers.openapi._files import end_read_transaction, merge_files +from controllers.openapi._hints import cursor_after, next_page_hint, page_after +from controllers.openapi._models import ( + AdvancedChatNodeRunPayload, + EnvVariableListResponse, + EnvVariableRow, + EnvVariableSetPayload, + EnvVariableValueType, + NodeRunPayload, + PublishPayload, + PublishResponse, + RestoreResponse, + RunListQuery, + RunListResponse, + VersionListQuery, + VersionListResponse, + VersionRow, +) +from controllers.openapi._upload import UploadParts +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import EDITOR_ROLES, Requirement, account_app_guards +from core.helper import encrypter +from core.workflow.llm_environment_variable import environment_variable_value_type +from extensions.ext_application_services import application_services +from factories import variable_factory +from fields.workflow_run_fields import ( + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunNodeExecutionResponse, + node_execution_response_source, + workflow_run_pagination_response_source, + workflow_run_response_source, +) +from graphon.variables import SecretVariable, VariableBase +from graphon.variables.exc import VariableError +from libs.datetime_utils import naive_utc_now +from libs.helper import to_timestamp +from models import App, AppMode +from models.workflow import Workflow, WorkflowDataError, WorkflowRun +from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError +from services.workflow.dsl_import import stored_secret_ids +from services.workflow.graph_check import CONTAINER_NODE_TYPES, GRAPH_MODES +from services.workflow.graph_diff import draft_token +from services.workflow_run_service import WorkflowRunListArgs +from services.workflow_service import WorkflowService +from services.workflow_variable_reference_validator import advisory_variable_reference_warning + +_RUN_READ_GUARDS: Final = account_app_guards( + RBACPermission.APP_CREATE_AND_MANAGEMENT, scope=Scope.APPS_READ, roles=None, modes=GRAPH_MODES +) +VERSION_READ_GUARDS: Final = account_app_guards( + RBACPermission.APP_VIEW_LAYOUT, scope=Scope.APPS_READ, roles=EDITOR_ROLES, modes=GRAPH_MODES +) +_RELEASE_GUARDS: Final = account_app_guards( + RBACPermission.APP_RELEASE_AND_VERSION, scope=Scope.WORKSPACE_WRITE, roles=EDITOR_ROLES, modes=GRAPH_MODES +) +_ENV_WRITE_GUARDS: Final = account_app_guards( + RBACPermission.APP_EDIT, scope=Scope.WORKSPACE_WRITE, roles=EDITOR_ROLES, modes=GRAPH_MODES +) + + +def _require_uuid(value: str, *, not_found: type[OpenApiError]) -> str: + """Parse a path id as a UUID or raise 404; an unparsed id would otherwise reach + a UUID-typed database column and fail as a 500.""" + try: + return str(UUID(value)) + except ValueError: + raise not_found() + + +def _require_run(ctx: Context, run_id: str) -> WorkflowRun: + run = application_services().workflow_runs.get_workflow_run( + ctx.request_context, app_id=ctx.app.id, run_id=_require_uuid(run_id, not_found=RunNotFound) + ) + if run is None: + raise RunNotFound() + return run + + +@openapi_ns.route("/apps//runs") +class AppRunListApi(Resource): + @endpoint( + op="get.run", + kind=Kind.OBJECT, + summary="List runs of a workflow or advanced-chat app, newest first", + examples=( + Example( + title="Latest failed runs from real use", + input={"app_id": "", "status": "failed", "triggered_from": "app-run"}, + ), + Example( + title="The latest draft test run of this app, by anyone", + input={"app_id": "", "triggered_from": "debugging", "limit": 1}, + ), + ), + requirements=_RUN_READ_GUARDS, + query=RunListQuery, + returns=(HTTPStatus.OK, RunListResponse, "Run list"), + ) + def get(self, ctx: Context, app_id: str, *, query: RunListQuery): + args: WorkflowRunListArgs = {"limit": query.limit} + if query.last_id is not None: + args["last_id"] = query.last_id + if query.status is not None: + args["status"] = query.status + pagination = application_services().workflow_runs.get_paginate_workflow_runs( + ctx.request_context, + app_id=ctx.app.id, + args=args, + triggered_from=query.triggered_from, + ) + page = RunListResponse.model_validate(workflow_run_pagination_response_source(pagination, session=ctx.session)) + page.hints = next_page_hint( + op=op_of(AppRunListApi.get), + path_args={"app_id": ctx.app.id}, + query=query, + page=cursor_after(last_id=page.data[-1].id if page.data else None, has_more=page.has_more), + ) + return page + + +@openapi_ns.route("/apps//runs/") +class AppRunDescribeApi(Resource): + @endpoint( + op="describe.run", + kind=Kind.OBJECT, + summary="One run: inputs, outputs, error, tokens and timing", + examples=(Example(title="Read one run", input={"app_id": "", "run_id": ""}),), + requirements=_RUN_READ_GUARDS, + returns=(HTTPStatus.OK, WorkflowRunDetailResponse, "Run detail"), + ) + def get(self, ctx: Context, app_id: str, run_id: str): + run = _require_run(ctx, run_id) + return WorkflowRunDetailResponse.model_validate(workflow_run_response_source(run, session=ctx.session)) + + +@openapi_ns.route("/apps//runs//nodes") +class AppRunNodeListApi(Resource): + @endpoint( + op="get.run.node", + kind=Kind.OBJECT, + summary="Each node step of a run, to find the one that failed", + examples=(Example(title="Node steps of one run", input={"app_id": "", "run_id": ""}),), + requirements=_RUN_READ_GUARDS, + returns=(HTTPStatus.OK, WorkflowRunNodeExecutionListResponse, "Node steps"), + ) + def get(self, ctx: Context, app_id: str, run_id: str): + run = _require_run(ctx, run_id) + steps = application_services().workflow_runs.get_workflow_run_node_executions( + ctx.request_context, app_id=ctx.app.id, run_id=run.id + ) + return WorkflowRunNodeExecutionListResponse.model_validate({"data": steps}) + + +@openapi_ns.route("/apps/:publish") +class AppPublishApi(Resource): + @endpoint( + op="publish.console_app", + kind=Kind.OBJECT, + summary="Publish the draft as a new version and make the app use it", + examples=( + Example(title="Publish the draft", input={"app_id": ""}), + Example( + title="Publish a named version", + input={"app_id": "", "marked_name": "v2", "marked_comment": "Adds a retry"}, + ), + ), + requirements=_RELEASE_GUARDS, + body=PublishPayload, + returns=(HTTPStatus.OK, PublishResponse, "Published"), + ) + def post(self, ctx: Context, app_id: str, *, body: PublishPayload): + try: + workflow = WorkflowService().publish_workflow( + session=ctx.session, + app_model=ctx.app, + account=ctx.account, + marked_name=body.marked_name, + marked_comment=body.marked_comment, + ) + except ValueError as exc: + raise BadRequest(str(exc)) from exc + app_in_session = ctx.session.get(App, ctx.app.id) + if app_in_session: + app_in_session.workflow_id = workflow.id + app_in_session.updated_by = ctx.account.id + app_in_session.updated_at = naive_utc_now() + return PublishResponse( + version_id=workflow.id, + created_at=to_timestamp(workflow.created_at), + warning=advisory_variable_reference_warning(workflow.graph), + ) + + +@openapi_ns.route("/apps//versions") +class AppVersionListApi(Resource): + @endpoint( + op="get.console_app.version", + kind=Kind.OBJECT, + summary="List published versions, newest first", + examples=( + Example(title="Latest versions", input={"app_id": ""}), + Example(title="Only named versions", input={"app_id": "", "named_only": True}), + ), + requirements=VERSION_READ_GUARDS, + query=VersionListQuery, + returns=(HTTPStatus.OK, VersionListResponse, "Version list"), + ) + def get(self, ctx: Context, app_id: str, *, query: VersionListQuery): + workflows, has_more = WorkflowService().get_all_published_workflow( + session=ctx.session, + app_model=ctx.app, + page=query.page, + limit=query.limit, + user_id=None, + named_only=query.named_only, + include_draft=False, + ) + page = VersionListResponse( + page=query.page, + limit=query.limit, + has_more=has_more, + data=[ + VersionRow.model_validate(workflow).model_copy(update={"current": workflow.id == ctx.app.workflow_id}) + for workflow in workflows + ], + ) + page.hints = next_page_hint( + op=op_of(AppVersionListApi.get), + path_args={"app_id": ctx.app.id}, + query=query, + page=page_after(page=query.page, limit=query.limit, has_more=has_more), + ) + return page + + +@openapi_ns.route("/apps//versions/:restore") +class AppVersionRestoreApi(Resource): + @endpoint( + op="restore.console_app.version", + kind=Kind.OBJECT, + summary="Copy a published version into the draft; publish again to make it live", + examples=( + Example(title="Restore an older version", input={"app_id": "", "version_id": ""}), + ), + requirements=_RELEASE_GUARDS, + returns=(HTTPStatus.OK, RestoreResponse, "Restored into the draft"), + ) + def post(self, ctx: Context, app_id: str, version_id: str): + version_id = _require_uuid(version_id, not_found=VersionNotFound) + try: + draft = WorkflowService().restore_published_workflow_to_draft( + app_model=ctx.app, workflow_id=version_id, account=ctx.account, session=ctx.session + ) + except IsDraftWorkflowError as exc: + raise VersionNotRestorable() from exc + except WorkflowNotFoundError as exc: + raise VersionNotFound() from exc + except ValueError as exc: + raise BadRequest(str(exc)) from exc + return RestoreResponse(result="success", draft_hash=draft_token(draft)) + + +def env_variable_rows(variables: Iterable[VariableBase]) -> list[EnvVariableRow]: + return [ + EnvVariableRow( + id=variable.id, + name=variable.name, + description=variable.description, + value_type=environment_variable_value_type(variable), + value=encrypter.full_mask_token() + if isinstance(variable, SecretVariable) and variable.value + else variable.value, + ) + for variable in variables + ] + + +def require_draft(ctx: Context) -> Workflow: + draft = WorkflowService().get_draft_workflow(app_model=ctx.app, session=ctx.session) + if draft is None: + raise DraftNotFound() + return draft + + +def _patch_env(ctx: Context, *, upserts: list[VariableBase], deletions: list[str]) -> None: + try: + WorkflowService().patch_draft_workflow_environment_variables( + app_model=ctx.app, + environment_variables=upserts, + deleted_environment_variable_ids=deletions, + account=ctx.account, + session=ctx.session, + ) + except ValueError as exc: + raise BadRequest(str(exc)) from exc + + +@openapi_ns.route("/apps//env") +class AppEnvListApi(Resource): + @endpoint( + op="get.console_app.env", + kind=Kind.OBJECT, + summary="Environment variables of the draft. Each has an id, which set and delete take, " + "and a name, which nodes use. Secrets with a value are masked; empty secrets read as empty", + examples=(Example(title="List environment variables", input={"app_id": ""}),), + requirements=VERSION_READ_GUARDS, + returns=(HTTPStatus.OK, EnvVariableListResponse, "Environment variables"), + ) + def get(self, ctx: Context, app_id: str): + return EnvVariableListResponse(data=env_variable_rows(require_draft(ctx).environment_variables)) + + +@openapi_ns.route("/apps//env/") +class AppEnvItemApi(Resource): + @endpoint( + op="set.console_app.env", + kind=Kind.OBJECT, + summary="Add or replace one environment variable of the draft, keyed by its id from get console_app env, " + "not its name. A new id (e.g. a fresh UUID) adds a variable. Others are left alone", + examples=( + Example( + title="Set an API key as a secret", + input={ + "app_id": "", + "env_id": "", + "name": "API_KEY", + "value_type": "secret", + "value": "sk-...", + }, + ), + ), + requirements=_ENV_WRITE_GUARDS, + body=EnvVariableSetPayload, + returns=(HTTPStatus.OK, SimpleResultResponse, "Variable set"), + ) + def put(self, ctx: Context, app_id: str, env_id: str, *, body: EnvVariableSetPayload): + draft = require_draft(ctx) + if body.value == encrypter.full_mask_token() and body.value_type != EnvVariableValueType.SECRET: + raise SecretMaskNotSecret() + [mapping] = Workflow.normalize_environment_variable_mappings([{**body.model_dump(mode="json"), "id": env_id}]) + if mapping["value"] == HIDDEN_VALUE and env_id not in stored_secret_ids(draft): + raise SecretMaskUnknownId() + try: + variable = variable_factory.build_environment_variable_from_mapping(mapping) + except VariableError as exc: + raise BadRequest(str(exc)) from exc + _patch_env(ctx, upserts=[variable], deletions=[]) + return SimpleResultResponse(result="success") + + @endpoint( + op="delete.console_app.env", + kind=Kind.OBJECT, + summary="Remove one environment variable from the draft, keyed by its id from get console_app env, " + "not its name. Others are left alone", + examples=(Example(title="Remove a variable", input={"app_id": "", "env_id": ""}),), + requirements=_ENV_WRITE_GUARDS, + returns=(HTTPStatus.OK, SimpleResultResponse, "Variable removed"), + ) + def delete(self, ctx: Context, app_id: str, env_id: str): + if all(variable.id != env_id for variable in require_draft(ctx).environment_variables): + raise EnvVariableNotFound() + _patch_env(ctx, upserts=[], deletions=[env_id]) + return SimpleResultResponse(result="success") + + +def _node_run_guards(mode: AppMode) -> tuple[Requirement, ...]: + return account_app_guards(RBACPermission.APP_TEST_AND_RUN, scope=Scope.APPS_RUN, roles=EDITOR_ROLES, modes=(mode,)) + + +def run_draft_node( + ctx: Context, node_id: str, *, inputs: dict[str, Any], query: str, files: UploadParts | None = None +) -> WorkflowRunNodeExecutionResponse: + workflow_service = WorkflowService() + draft = require_draft(ctx) + try: + node = draft.get_node_config_by_id(node_id) + except WorkflowDataError as exc: + raise NodeNotFound() from exc + except ValueError as exc: + raise BadRequest(str(exc)) from exc + if Workflow.get_node_type_from_node_config(node) in CONTAINER_NODE_TYPES: + raise BadRequest("Loop and iteration nodes can't run alone; test them with a full draft run") + if files: + end_read_transaction(ctx.session) + user_inputs = merge_files(inputs, files, ctx.caller) + try: + execution = workflow_service.run_draft_workflow_node( + app_model=ctx.app, + draft_workflow=draft, + node_id=node_id, + user_inputs=user_inputs, + account=ctx.account, + query=query, + ) + except ValueError as exc: + raise BadRequest(str(exc)) from exc + return WorkflowRunNodeExecutionResponse.model_validate( + node_execution_response_source(execution, session=ctx.session), from_attributes=True + ) + + +@openapi_ns.route("/apps//draft/workflow/nodes/:run") +class WorkflowDraftNodeRunApi(Resource): + @endpoint( + op="test.node.workflow", + kind=Kind.OBJECT, + summary="Test one node of a workflow draft, reusing what the last draft run saved", + examples=( + Example(title="Test a node", input={"app_id": "", "node_id": ""}), + Example( + title="Test a node with an upstream value overridden", + input={"app_id": "", "node_id": "", "inputs": {"#llm.text#": "Hello"}}, + ), + ), + requirements=_node_run_guards(AppMode.WORKFLOW), + body=NodeRunPayload, + returns=(HTTPStatus.OK, WorkflowRunNodeExecutionResponse, "Node execution"), + ) + def post(self, ctx: Context, app_id: str, node_id: str, *, body: NodeRunPayload): + return run_draft_node(ctx, node_id, inputs=body.inputs, query="", files=body.files) + + +@openapi_ns.route("/apps//draft/advanced-chat/nodes/:run") +class AdvancedChatDraftNodeRunApi(Resource): + @endpoint( + op="test.node.advanced_chat", + kind=Kind.OBJECT, + summary="Test one node of an advanced-chat draft, reusing what the last draft run saved", + examples=(Example(title="Test a node", input={"app_id": "", "node_id": "", "query": "Hi"}),), + requirements=_node_run_guards(AppMode.ADVANCED_CHAT), + body=AdvancedChatNodeRunPayload, + returns=(HTTPStatus.OK, WorkflowRunNodeExecutionResponse, "Node execution"), + ) + def post(self, ctx: Context, app_id: str, node_id: str, *, body: AdvancedChatNodeRunPayload): + return run_draft_node(ctx, node_id, inputs=body.inputs, query=body.query, files=body.files) diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index 890d88de50f..8fe40a41fc4 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -2,7 +2,9 @@ from __future__ import annotations +from collections.abc import Callable from http import HTTPStatus +from typing import Any, Final from flask_restx import Resource from werkzeug.exceptions import NotFound @@ -11,21 +13,75 @@ from constants.oauth_bearer import Scope from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission from controllers.openapi import openapi_ns from controllers.openapi._app_response import app_list_row, build_app_describe_response -from controllers.openapi._contract import Example, Kind, endpoint -from controllers.openapi._models import AppDescribeQuery, AppDescribeResponse, AppListQuery, AppListResponse +from controllers.openapi._contract import Example, Kind, endpoint, op_of +from controllers.openapi._models import AppDescribeQuery, AppDescribeResponse, AppListQuery, AppListResponse, Hint +from controllers.openapi.app_info import ( + AdvancedChatAppInfoApi, + AgentAppInfoApi, + AgentChatAppInfoApi, + AgentServiceApiApi, + ChatAppInfoApi, + CompletionAppInfoApi, + ServiceApiApi, + WorkflowAppInfoApi, +) +from controllers.openapi.app_webapp import ( + AdvancedChatWebAppApi, + AgentChatWebAppApi, + AgentWebAppAccessApi, + AgentWebAppApi, + ChatWebAppApi, + CompletionWebAppApi, + WebAppAccessApi, + WorkflowWebAppApi, +) from controllers.openapi.auth.requirements import ( CheckAppApiEnabled, CheckRBACPermission, CheckScope, CheckSubject, + CheckWebAppAuthEnterprise, CheckWorkspaceMember, ) from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject from extensions.ext_application_services import application_services from machinery.context import AppRequestContext, RequestContext +from models.model import AppMode from services.app.query_service import AppDiscoveryQuery from services.errors.app import AppDiscoveryNotFoundError +_SETTINGS_VIEWS: Final[dict[AppMode, tuple[Callable[..., object], ...]]] = { + AppMode.WORKFLOW: (WorkflowAppInfoApi.get, WorkflowWebAppApi.get, ServiceApiApi.get, WebAppAccessApi.get), + AppMode.ADVANCED_CHAT: ( + AdvancedChatAppInfoApi.get, + AdvancedChatWebAppApi.get, + ServiceApiApi.get, + WebAppAccessApi.get, + ), + AppMode.CHAT: (ChatAppInfoApi.get, ChatWebAppApi.get, ServiceApiApi.get, WebAppAccessApi.get), + AppMode.AGENT_CHAT: (AgentChatAppInfoApi.get, AgentChatWebAppApi.get, ServiceApiApi.get, WebAppAccessApi.get), + AppMode.COMPLETION: (CompletionAppInfoApi.get, CompletionWebAppApi.get, ServiceApiApi.get, WebAppAccessApi.get), + AppMode.AGENT: (AgentAppInfoApi.get, AgentWebAppApi.get, AgentServiceApiApi.get, AgentWebAppAccessApi.get), +} + + +def _webapp_auth_gated(view: Any) -> bool: + return any(isinstance(r, CheckWebAppAuthEnterprise) for r in view.__spec__.requirements) + + +def settings_hints(app_id: str, mode: str) -> list[Hint]: + """A stored mode this build doesn't know gets no hints, and the web-app access setting only where it exists.""" + try: + app_mode = AppMode(mode) + except ValueError: + return [] + webapp_auth = CheckWebAppAuthEnterprise.available() + return [ + Hint(summary="Read this setting", op=op_of(view), input={"app_id": app_id}) + for view in _SETTINGS_VIEWS.get(app_mode, ()) + if webapp_auth or not _webapp_auth_gated(view) + ] + @openapi_ns.route("/apps/") class AppDescribeApi(Resource): @@ -56,7 +112,9 @@ class AppDescribeApi(Resource): result = application_services().apps.discovery.describe(ctx, query.fields) except AppDiscoveryNotFoundError as exc: raise NotFound(str(exc)) from exc - return build_app_describe_response(result, query.fields) + response = build_app_describe_response(result, query.fields) + response.hints = settings_hints(result.app.id, result.app.mode) + return response @openapi_ns.route("/apps") diff --git a/api/controllers/openapi/auth/requirements.py b/api/controllers/openapi/auth/requirements.py index 6b33085b263..2caf4382af8 100644 --- a/api/controllers/openapi/auth/requirements.py +++ b/api/controllers/openapi/auth/requirements.py @@ -9,9 +9,9 @@ Per-request caching belongs in `loaders.py`, which stores into `Context`. from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Sequence +from collections.abc import Collection, Sequence from enum import IntEnum -from typing import ClassVar, override +from typing import ClassVar, Final, override from flask import request from sqlalchemy.orm import Session @@ -19,19 +19,30 @@ from werkzeug.exceptions import Forbidden from configs import dify_config from constants.oauth_bearer import Scope -from controllers.common.rbac import RBACCheck, enforce_rbac_checks +from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, Workspace, enforce_rbac_checks from controllers.openapi._audit import emit_wrong_surface -from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded +from controllers.openapi._errors import ( + AppModeMismatch, + MemberLicenseExceeded, + MemberLimitExceeded, + PluginInstallForbidden, + ServiceApiDisabled, + WebAppAccessRequiresEE, +) +from controllers.openapi._models import Hint from controllers.openapi.auth.context import Context from controllers.openapi.auth.loaders import load_app, load_caller, load_workspace, load_workspace_role -from controllers.openapi.auth.subjects import ResourceAccessSubject, Subject +from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject, Subject from enums import DeploymentEdition from extensions.ext_application_services import application_services -from models.account import TenantAccountRole +from models import AppMode +from models.account import TenantAccountRole, TenantPluginInstallPermission from models.enums import CreatorUserRole from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode from services.entities.feature_entities import LicenseStatus from services.errors.workspace import WorkspaceInvitationQuotaError, WorkspaceMemberLicenseQuotaError +from services.feature_service import FeatureService +from services.plugin.plugin_permission_service import PluginPermissionService from services.system_feature_service import SystemFeatureService _DEAD_LICENSE_STATUSES = frozenset({LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST}) @@ -80,13 +91,21 @@ def assert_license_valid() -> None: raise Forbidden("license_invalid") +# The routes live in `app_info`, which imports this module, so the op ids are spelled out; a test pins them. +SET_SERVICE_API_OP: Final = "set.service_api" +SET_AGENT_SERVICE_API_OP: Final = "set.service_api.agent" + + class CheckAppApiEnabled(Requirement): rank = Rank.EARLY @override def run(self, subject: Subject, ctx: Context, session: Session) -> None: - if not load_app(ctx).enable_api: - raise Forbidden("service_api_disabled") + app = load_app(ctx) + if not app.enable_api: + op = SET_AGENT_SERVICE_API_OP if app.mode == AppMode.AGENT else SET_SERVICE_API_OP + hint = Hint(summary="Turn the Service API on", op=op, input={"app_id": app.id, "enabled": True}) + raise ServiceApiDisabled(hints=[hint]) class CheckWorkspaceMember(Requirement): @@ -157,6 +176,50 @@ class CheckRBACPermission(Requirement): ) +EDITOR_ROLES: Final = frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}) +ADMIN_ROLES: Final = frozenset({TenantAccountRole.ADMIN, TenantAccountRole.OWNER}) + + +class CheckAppMode(Requirement): + """Ranked ahead of RBAC so a mode mismatch answers 422 whether RBAC is on or off.""" + + rank = Rank.EARLY + + def __init__(self, *modes: AppMode) -> None: + self.modes = frozenset(modes) + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if load_app(ctx).mode not in self.modes: + raise AppModeMismatch() + + +class CheckWebAppAuthEnterprise(Requirement): + @staticmethod + def available() -> bool: + return ( + dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE + and SystemFeatureService.get_public_system_features().webapp_auth.enabled + ) + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if not self.available(): + raise WebAppAccessRequiresEE() + + +class CheckAppQuota(Requirement): + """The console's `billing_resource="apps"` gate.""" + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: + return + apps = FeatureService.get_features(load_workspace(ctx).id, exclude_vector_space=True).apps + if 0 < apps.limit <= apps.size: + raise Forbidden("The number of apps has reached the limit of your subscription.") + + class CheckWorkspaceRole(Requirement): """The workspace-role gate that predates RBAC. Inert wherever RBAC is on; a route that needs a check there declares a `CheckRBACPermission` beside this. @@ -175,6 +238,34 @@ class CheckWorkspaceRole(Requirement): raise Forbidden("insufficient workspace role") +class CheckPluginInstallSetting(Requirement): + """The console's `plugin_permission_required(install_required=True)`: the workspace's + "who can install plugins" setting. Inert wherever RBAC is on, as in the console. + A setting value this code does not know is refused. + """ + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if subject.caller_role is not CreatorUserRole.ACCOUNT: + return + if dify_config.RBAC_ENABLED: + return + setting = PluginPermissionService.get_permission(load_workspace(ctx).id, session=session) + if setting is None: + return + match setting.install_permission: + case TenantPluginInstallPermission.EVERYONE: + return + case TenantPluginInstallPermission.ADMINS: + if load_workspace_role(ctx) in ADMIN_ROLES: + return + raise PluginInstallForbidden("Plugin install is limited to admins in this workspace.") + case TenantPluginInstallPermission.NOBODY: + raise PluginInstallForbidden("Plugin install is turned off in this workspace.") + case unknown: + raise PluginInstallForbidden(f"Plugin install setting {unknown!r} is not known to this server.") + + class CheckAppAccess(Requirement): """Run-scope comes from the declaration site, so it is not re-checked here. @@ -224,3 +315,59 @@ class ResolveCaller(Requirement): if not subject.mounts_caller(ctx): return load_caller(ctx) + + +Roles = frozenset[TenantAccountRole] | None +"""The pre-RBAC workspace-role gate; None declares none.""" + +WORKSPACE_READ_GUARDS: Final[tuple[Requirement, ...]] = ( + CheckSubject(allowed=(AccountSubject,)), + CheckScope(Scope.WORKSPACE_READ), + CheckWorkspaceMember(), +) + + +def _role_gate(roles: Roles) -> tuple[Requirement, ...]: + return (CheckWorkspaceRole(roles),) if roles is not None else () + + +def workspace_write_guards( + permission: RBACPermission, *, roles: Roles, extra: tuple[Requirement, ...] = () +) -> tuple[Requirement, ...]: + """Guards for an account-only write to the workspace.""" + return ( + CheckSubject(allowed=(AccountSubject,)), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(permission, Workspace())), + *_role_gate(roles), + *extra, + ) + + +def _app_guards( + head: tuple[Requirement, ...], check: RBACCheck, *, scope: Scope, modes: Collection[AppMode], roles: Roles +) -> tuple[Requirement, ...]: + return ( + *head, + *((CheckAppMode(*modes),) if modes else ()), + CheckScope(scope), + CheckRBACPermission(check), + *_role_gate(roles), + ) + + +def account_app_guards( + permission: RBACPermission, *, scope: Scope, roles: Roles, modes: Collection[AppMode] = () +) -> tuple[Requirement, ...]: + """Guards for an account-only op on one app; `modes` limits it to apps of those modes.""" + head = (CheckSubject(allowed=(AccountSubject,)), CheckAppApiEnabled(), CheckWorkspaceMember()) + return _app_guards(head, RBACCheck(permission, PlainApp()), scope=scope, modes=modes, roles=roles) + + +def account_settings_guards( + check: RBACCheck, *, scope: Scope, modes: Collection[AppMode], roles: Roles +) -> tuple[Requirement, ...]: + """App-settings guards. No `CheckAppApiEnabled`, so an admin can always switch the Service API back on.""" + head = (CheckSubject(allowed=(AccountSubject,)), CheckWorkspaceMember()) + return _app_guards(head, check, scope=scope, modes=modes, roles=roles) diff --git a/api/controllers/openapi/human_input_form.py b/api/controllers/openapi/human_input_form.py index e7fdabd9e1c..f89da60e841 100644 --- a/api/controllers/openapi/human_input_form.py +++ b/api/controllers/openapi/human_input_form.py @@ -197,7 +197,22 @@ class OpenApiWorkflowHumanInputFormSubmitApi(Resource): except FormNotFoundError: raise HumanInputFormNotFound() - return FormSubmitResponse() + if form.workflow_run_id is None: + return FormSubmitResponse() + # `workflow_events` imports this module for `with_form_hints`. + from controllers.openapi.workflow_events import OpenApiWorkflowEventsApi + + follow = Hint( + summary="Follow the resumed run", + op=op_of(OpenApiWorkflowEventsApi.get), + input={ + "app_id": ctx.app.id, + "task_id": form.workflow_run_id, + "include_state_snapshot": True, + "continue_on_pause": False, + }, + ) + return FormSubmitResponse(hints=[follow]) def form_hints(*, op: str, app_id: str, response: HumanInputRequiredResponse) -> list[Hint]: diff --git a/api/controllers/openapi/knowledge_bases.py b/api/controllers/openapi/knowledge_bases.py new file mode 100644 index 00000000000..9749c45632a --- /dev/null +++ b/api/controllers/openapi/knowledge_bases.py @@ -0,0 +1,55 @@ +"""Knowledge bases a workflow's knowledge-retrieval node can search.""" + +from __future__ import annotations + +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any + +from flask_restx import Resource + +from controllers.openapi import openapi_ns +from controllers.openapi._contract import Example, Kind, endpoint +from controllers.openapi._models import ( + KnowledgeBaseListQuery, + KnowledgeBaseListResponse, + KnowledgeBaseRow, + KnowledgeRetrievalFragment, +) +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import WORKSPACE_READ_GUARDS +from extensions.ext_application_services import application_services +from services.knowledge.datasets.application import DatasetListFilter + + +def knowledge_base_row(record: Mapping[str, Any]) -> KnowledgeBaseRow: + return KnowledgeBaseRow( + id=record["id"], + name=record["name"], + description=record.get("description"), + provider=record["provider"], + indexing_technique=record.get("indexing_technique"), + document_count=record["document_count"], + usable=record.get("embedding_available") is not False, + node_data=KnowledgeRetrievalFragment(dataset_ids=[record["id"]]), + ) + + +@openapi_ns.route("/workspaces//knowledge-bases") +class KnowledgeBasesApi(Resource): + @endpoint( + op="get.knowledge_base", + kind=Kind.LIST, + summary="Knowledge bases you can use, each with the knowledge-retrieval node data to paste", + examples=(Example(title="Find product docs", input={"query": "product"}),), + requirements=WORKSPACE_READ_GUARDS, + query=KnowledgeBaseListQuery, + returns=(HTTPStatus.OK, KnowledgeBaseListResponse, "Knowledge bases"), + ) + def get(self, ctx: Context, workspace_id: str, *, query: KnowledgeBaseListQuery): + page = application_services().knowledge.datasets.list_datasets( + ctx.request_context, DatasetListFilter(page=query.page, limit=query.limit, keyword=query.query or None) + ) + return KnowledgeBaseListResponse.build( + page=query.page, limit=query.limit, total=page["total"], items=[knowledge_base_row(r) for r in page["data"]] + ) diff --git a/api/controllers/openapi/model_providers.py b/api/controllers/openapi/model_providers.py new file mode 100644 index 00000000000..156dee3ee1d --- /dev/null +++ b/api/controllers/openapi/model_providers.py @@ -0,0 +1,430 @@ +"""Model providers in one workspace: what is set up, their credential forms, credentials, models and defaults.""" + +from __future__ import annotations + +from collections.abc import Generator, Iterable, Sequence +from contextlib import contextmanager +from http import HTTPStatus +from typing import Any, Final + +from flask_restx import Resource +from werkzeug.exceptions import BadRequest + +from controllers.common.rbac import RBACPermission +from controllers.openapi import openapi_ns +from controllers.openapi._contract import Example, Kind, endpoint, op_of +from controllers.openapi._errors import CredentialInvalid, ProviderNotFound +from controllers.openapi._i18n import localized +from controllers.openapi._models import ( + CredentialFormField, + CredentialRef, + CredentialWriteResponse, + CustomModelRow, + Hint, + LlmModelBlock, + ModelCredentialCreatePayload, + ModelCredentialUpdatePayload, + ModelListQuery, + ModelListResponse, + ModelProviderDetailResponse, + ModelRef, + ModelRow, + ProviderCredentialCreatePayload, + ProviderCredentialUpdatePayload, +) +from controllers.openapi._search import matches +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ADMIN_ROLES, WORKSPACE_READ_GUARDS, workspace_write_guards +from core.entities.model_entities import ModelStatus, ModelWithProviderEntity +from core.entities.provider_entities import CustomModelConfiguration +from extensions.ext_application_services import application_services +from graphon.model_runtime.entities.model_entities import ModelPropertyKey, ModelType +from graphon.model_runtime.entities.provider_entities import CredentialFormSchema +from graphon.model_runtime.errors.validate import CredentialsValidateFailedError +from services.entities.model_provider_entities import CustomConfigurationStatus, ProviderResponse +from services.errors.app_model_config import ProviderNotFoundError +from services.model_provider.service import ModelProviderService + +_CREATE: Final = workspace_write_guards(RBACPermission.CREDENTIAL_CREATE, roles=ADMIN_ROLES) +_MANAGE: Final = workspace_write_guards(RBACPermission.CREDENTIAL_MANAGE, roles=ADMIN_ROLES) + +_PROVIDER_EXAMPLE: Final = "langgenius/openai/openai" +_OLLAMA_EXAMPLE: Final = "langgenius/ollama/ollama" +_PROVIDER_PATH: Final = "/workspaces//model-providers/" + + +@contextmanager +def _model_provider_errors() -> Generator[None]: + """Maps what the model provider service raises for a credential write or a provider lookup.""" + try: + yield + except ProviderNotFoundError as error: + raise ProviderNotFound(str(error)) from error + except CredentialsValidateFailedError as error: + raise CredentialInvalid(str(error)) from error + except ValueError as error: + raise BadRequest(str(error)) from error + + +def _form(schemas: Sequence[CredentialFormSchema], language: str | None) -> list[CredentialFormField]: + return [ + CredentialFormField.of( + name=field.variable, + type=field.type, + required=field.required, + label=field.label, + placeholder=field.placeholder, + options=field.options, + show_on=field.show_on, + language=language, + ) + for field in schemas + ] + + +def _provider(workspace_id: str, provider: str) -> ProviderResponse: + with _model_provider_errors(): + return ModelProviderService().get_provider(workspace_id, provider) + + +def _active(response: ProviderResponse) -> CredentialRef | None: + config = response.custom_configuration + if not config.current_credential_id: + return None + return CredentialRef(id=config.current_credential_id, name=config.current_credential_name) + + +def _configured(response: ProviderResponse) -> bool: + return response.custom_configuration.status == CustomConfigurationStatus.ACTIVE + + +def _row(response: ProviderResponse, language: str | None) -> dict[str, Any]: + return { + "provider": response.provider, + "label": localized(response.label, language), + "model_types": [str(model_type) for model_type in response.supported_model_types], + "configured": _configured(response), + "active_credential": _active(response), + } + + +def _custom_model(response: ProviderResponse, ref: ModelRef) -> CustomModelConfiguration | None: + for custom in response.custom_configuration.custom_models or []: + if custom.model == ref.model and custom.model_type == ref.model_type: + return custom + return None + + +def _provider_write_response( + workspace_id: str, response: ProviderResponse, credential_id: str +) -> CredentialWriteResponse: + configuration = response.custom_configuration + names = {c.credential_id: c.credential_name for c in configuration.available_credentials or []} + active = configuration.current_credential_id == credential_id + hints: list[Hint] = [] + if active: + hints.append( + Hint( + summary="Pick a model for your nodes", + op=op_of(ModelsApi.get), + input={"workspace_id": workspace_id, "provider": response.provider}, + ) + ) + elif configuration.current_credential_id: + hints.append( + Hint( + summary="Another credential is active; replace its values to use these", + op=op_of(ModelProviderCredentialApi.patch), + input={ + "workspace_id": workspace_id, + "provider": response.provider, + "credential_id": configuration.current_credential_id, + "credentials": None, + }, + ) + ) + return CredentialWriteResponse(id=credential_id, name=names.get(credential_id), active=active, hints=hints) + + +def _model_write_response( + workspace_id: str, response: ProviderResponse, ref: ModelRef, credential_id: str +) -> CredentialWriteResponse: + custom = _custom_model(response, ref) + names = {c.credential_id: c.credential_name for c in (custom.available_model_credentials if custom else [])} + active = custom is not None and custom.current_credential_id == credential_id + hints = ( + [ + Hint( + summary="Another credential is active for the model; replace its values to use these", + op=op_of(ModelCredentialApi.patch), + input={ + "workspace_id": workspace_id, + "provider": response.provider, + "credential_id": custom.current_credential_id, + "model": ref.model, + "model_type": ref.model_type.value, + "credentials": None, + }, + ) + ] + if custom is not None and custom.current_credential_id and not active + else [] + ) + return CredentialWriteResponse(id=credential_id, name=names.get(credential_id), active=active, hints=hints) + + +def model_rows(models: Iterable[ModelWithProviderEntity], *, words: str, language: str | None) -> list[ModelRow]: + rows = [] + for m in models: + if m.deprecated: + continue + row = ModelRow( + provider=m.provider.provider, + provider_label=localized(m.provider.label, language), + model=m.model, + model_type=str(m.model_type), + label=localized(m.label, language), + status=str(m.status), + features=[str(f) for f in m.features or []], + node_model=LlmModelBlock(provider=m.provider.provider, name=m.model, mode=str(mode)) + if m.model_type == ModelType.LLM and (mode := m.model_properties.get(ModelPropertyKey.MODE)) + else None, + ) + if matches(words, row.model, row.label, row.provider, row.provider_label): + rows.append(row) + return rows + + +@openapi_ns.route(_PROVIDER_PATH) +class ModelProviderApi(Resource): + @endpoint( + op="describe.model_provider", + kind=Kind.OBJECT, + summary="One model provider with its credential form and saved credentials (names only)", + examples=(Example(title="OpenAI", input={"provider": _PROVIDER_EXAMPLE}),), + requirements=WORKSPACE_READ_GUARDS, + returns=(HTTPStatus.OK, ModelProviderDetailResponse, "Model provider"), + ) + def get(self, ctx: Context, workspace_id: str, provider: str): + response = _provider(ctx.workspace.id, provider) + language = ctx.account.interface_language + form = _form( + response.provider_credential_schema.credential_form_schemas if response.provider_credential_schema else [], + language, + ) + credentials = [ + CredentialRef(id=c.credential_id, name=c.credential_name) + for c in ModelProviderService().get_provider_available_credentials( + ctx.workspace.id, provider, ctx.account, credential_query=application_services().credential_queries + ) + ] + model_schema = response.model_credential_schema + hints = ( + [] + if _configured(response) or not form + else [ + Hint( + summary="Ask the user for these values, then save them", + op=op_of(ModelProviderCredentialsApi.post), + input={"workspace_id": ctx.workspace.id, "provider": provider, "credentials": None}, + form=[field.model_dump() for field in form], + ) + ] + ) + hints.append( + Hint( + summary="List its models", + op=op_of(ModelsApi.get), + input={"workspace_id": ctx.workspace.id, "provider": provider}, + ) + ) + return ModelProviderDetailResponse( + **_row(response, language), + credential_form=form, + credentials=credentials, + custom_model_form=_form(model_schema.credential_form_schemas, language) if model_schema else None, + custom_models=[ + CustomModelRow( + model=custom.model, + model_type=str(custom.model_type), + active_credential=CredentialRef( + id=custom.current_credential_id, name=custom.current_credential_name + ) + if custom.current_credential_id + else None, + ) + for custom in response.custom_configuration.custom_models or [] + ], + hints=hints, + ) + + +@openapi_ns.route(f"{_PROVIDER_PATH}/credentials") +class ModelProviderCredentialsApi(Resource): + @endpoint( + op="create.model_provider.credential", + kind=Kind.OBJECT, + summary="Save a credential for a model provider; the provider checks it first", + examples=( + Example( + title="Save an OpenAI key (pass credentials with @-)", + input={"provider": _PROVIDER_EXAMPLE, "credentials": {"openai_api_key": ""}}, + ), + ), + requirements=_CREATE, + body=ProviderCredentialCreatePayload, + returns=(HTTPStatus.CREATED, CredentialWriteResponse, "Credential saved"), + ) + def post(self, ctx: Context, workspace_id: str, provider: str, *, body: ProviderCredentialCreatePayload): + with _model_provider_errors(): + credential_id = ModelProviderService().create_provider_credential( + tenant_id=ctx.workspace.id, provider=provider, credentials=body.credentials, credential_name=body.name + ) + response = _provider(ctx.workspace.id, provider) + return _provider_write_response(ctx.workspace.id, response, credential_id), HTTPStatus.CREATED + + +@openapi_ns.route(f"{_PROVIDER_PATH}/credentials/") +class ModelProviderCredentialApi(Resource): + @endpoint( + op="set.model_provider.credential", + kind=Kind.OBJECT, + summary="Replace a saved model provider credential", + examples=( + Example( + title="Rotate the key", + input={ + "provider": _PROVIDER_EXAMPLE, + "credential_id": "", + "credentials": {"openai_api_key": ""}, + }, + ), + ), + requirements=_MANAGE, + body=ProviderCredentialUpdatePayload, + returns=(HTTPStatus.OK, CredentialWriteResponse, "Credential replaced"), + ) + def patch( + self, + ctx: Context, + workspace_id: str, + provider: str, + credential_id: str, + *, + body: ProviderCredentialUpdatePayload, + ): + with _model_provider_errors(): + ModelProviderService().update_provider_credential( + tenant_id=ctx.workspace.id, + provider=provider, + credentials=body.credentials, + credential_id=credential_id, + credential_name=body.name, + ) + return _provider_write_response(ctx.workspace.id, _provider(ctx.workspace.id, provider), credential_id) + + +@openapi_ns.route("/workspaces//models") +class ModelsApi(Resource): + @endpoint( + op="get.model", + kind=Kind.LIST, + summary="Models in the workspace across providers, with whether each can be used now", + examples=( + Example(title="LLMs", input={"model_type": "llm"}), + Example(title="OpenAI models", input={"provider": _PROVIDER_EXAMPLE}), + ), + requirements=WORKSPACE_READ_GUARDS, + query=ModelListQuery, + returns=(HTTPStatus.OK, ModelListResponse, "Models"), + ) + def get(self, ctx: Context, workspace_id: str, *, query: ModelListQuery): + models = ModelProviderService().list_models( + ctx.workspace.id, provider=query.provider, model_type=query.model_type + ) + rows = model_rows(models, words=query.query, language=ctx.account.interface_language) + page = ModelListResponse.page_of(rows, query=query) + unready = sorted({row.provider for row in page.data if row.status != ModelStatus.ACTIVE}) + page.hints = [ + Hint( + summary="Set up this provider to use its models", + op=op_of(ModelProviderApi.get), + input={"workspace_id": ctx.workspace.id, "provider": provider}, + ) + for provider in unready + ] + return page + + +@openapi_ns.route(f"{_PROVIDER_PATH}/models/credentials") +class ModelCredentialsApi(Resource): + @endpoint( + op="create.model.credential", + kind=Kind.OBJECT, + summary="Save a credential for one model you name, for providers such as OpenAI-compatible or Ollama; " + "the first credential of a model not yet set up becomes its active credential", + examples=( + Example( + title="A self-hosted model", + input={ + "provider": _OLLAMA_EXAMPLE, + "model": "llama3", + "model_type": "llm", + "credentials": {"base_url": "http://ollama:11434"}, + }, + ), + ), + requirements=_CREATE, + body=ModelCredentialCreatePayload, + returns=(HTTPStatus.CREATED, CredentialWriteResponse, "Credential saved"), + ) + def post(self, ctx: Context, workspace_id: str, provider: str, *, body: ModelCredentialCreatePayload): + with _model_provider_errors(): + credential_id = ModelProviderService().create_model_credential( + tenant_id=ctx.workspace.id, + provider=provider, + model_type=body.model_type.value, + model=body.model, + credentials=body.credentials, + credential_name=body.name, + ) + response = _provider(ctx.workspace.id, provider) + return _model_write_response(ctx.workspace.id, response, body, credential_id), HTTPStatus.CREATED + + +@openapi_ns.route(f"{_PROVIDER_PATH}/models/credentials/") +class ModelCredentialApi(Resource): + @endpoint( + op="set.model.credential", + kind=Kind.OBJECT, + summary="Replace a saved credential of one model", + examples=( + Example( + title="Rotate", + input={ + "provider": _OLLAMA_EXAMPLE, + "credential_id": "", + "model": "llama3", + "model_type": "llm", + "credentials": {"base_url": "http://ollama:11434"}, + }, + ), + ), + requirements=_MANAGE, + body=ModelCredentialUpdatePayload, + returns=(HTTPStatus.OK, CredentialWriteResponse, "Credential replaced"), + ) + def patch( + self, ctx: Context, workspace_id: str, provider: str, credential_id: str, *, body: ModelCredentialUpdatePayload + ): + with _model_provider_errors(): + ModelProviderService().update_model_credential( + tenant_id=ctx.workspace.id, + provider=provider, + model_type=body.model_type.value, + model=body.model, + credentials=body.credentials, + credential_id=credential_id, + credential_name=body.name, + ) + return _model_write_response(ctx.workspace.id, _provider(ctx.workspace.id, provider), body, credential_id) diff --git a/api/controllers/openapi/node_types.py b/api/controllers/openapi/node_types.py new file mode 100644 index 00000000000..0e912c77ddd --- /dev/null +++ b/api/controllers/openapi/node_types.py @@ -0,0 +1,67 @@ +"""Node types the server can run: server knowledge, not workspace data.""" + +from __future__ import annotations + +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any + +from flask_restx import Resource +from pydantic import BaseModel + +from controllers.openapi import openapi_ns +from controllers.openapi._contract import Example, Kind, endpoint +from controllers.openapi._errors import NodeTypeNotFound +from controllers.openapi._models import NodeTypeDetailResponse, NodeTypeListResponse, NodeTypeRow +from controllers.openapi.auth.context import Context +from core.workflow.node_factory import LATEST_VERSION, get_node_type_classes_mapping +from services.workflow.node_defaults import complete_sub_models, node_data_type +from services.workflow_service import WorkflowService + + +def _complete_default_config(model: type[BaseModel], default_config: Mapping[str, Any]) -> dict[str, Any]: + config = default_config.get("config") + if not isinstance(config, Mapping): + return dict(default_config) + return {**default_config, "config": complete_sub_models(model, config)} + + +@openapi_ns.route("/node-types") +class NodeTypeListApi(Resource): + @endpoint( + op="get.node_type", + kind=Kind.OBJECT, + summary="Every node type this server can run; import decides which a mode allows", + examples=(Example(title="List node types", input={}),), + requirements=(), + returns=(HTTPStatus.OK, NodeTypeListResponse, "Node types"), + ) + def get(self, ctx: Context): + rows = [ + NodeTypeRow(type=str(node_type), version=versions[LATEST_VERSION].version()) + for node_type, versions in get_node_type_classes_mapping().items() + ] + return NodeTypeListResponse(data=sorted(rows, key=lambda row: row.type)) + + +@openapi_ns.route("/node-types/") +class NodeTypeDetailApi(Resource): + @endpoint( + op="describe.node_type", + kind=Kind.OBJECT, + summary="The JSON schema of a node type's data and its default config", + examples=(Example(title="Describe the LLM node", input={"node_type": "llm"}),), + requirements=(), + returns=(HTTPStatus.OK, NodeTypeDetailResponse, "Node type"), + ) + def get(self, ctx: Context, node_type: str): + node_class = get_node_type_classes_mapping().get(node_type, {}).get(LATEST_VERSION) + if node_class is None: + raise NodeTypeNotFound() + model = node_data_type(node_type, node_class) + return NodeTypeDetailResponse( + type=node_type, + version=node_class.version(), + schema=model.model_json_schema(), + default_config=_complete_default_config(model, WorkflowService().get_default_block_config(node_type)), + ) diff --git a/api/controllers/openapi/plugins.py b/api/controllers/openapi/plugins.py new file mode 100644 index 00000000000..4a1dcc6bfae --- /dev/null +++ b/api/controllers/openapi/plugins.py @@ -0,0 +1,303 @@ +"""Plugins in one workspace: find them on the marketplace, install, check the install, upgrade, remove.""" + +from __future__ import annotations + +from collections.abc import Generator, Sequence +from contextlib import contextmanager +from http import HTTPStatus +from typing import Final + +import httpx +from flask_restx import Resource +from werkzeug.exceptions import BadRequest + +from configs import dify_config +from controllers.common.rbac import RBACPermission +from controllers.openapi import openapi_ns +from controllers.openapi._contract import Example, Kind, endpoint, op_of +from controllers.openapi._errors import ( + MarketplaceDisabled, + MarketplaceUnavailable, + PluginInstallForbidden, + PluginNotInstalled, + PluginServiceUnavailable, +) +from controllers.openapi._i18n import localized +from controllers.openapi._models import ( + Hint, + MarketplacePluginListResponse, + MarketplacePluginQuery, + MarketplacePluginRow, + PluginDeleteResponse, + PluginInstallPayload, + PluginListQuery, + PluginListResponse, + PluginProvides, + PluginRow, + PluginTaskItem, + PluginTaskResponse, + PluginTaskStartResponse, +) +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ( + WORKSPACE_READ_GUARDS, + CheckPluginInstallSetting, + workspace_write_guards, +) +from controllers.openapi.model_providers import ModelProviderApi +from controllers.openapi.tool_providers import ToolProviderApi +from core.helper.marketplace import MarketplaceSearchUnavailableError, search_plugins +from core.plugin.entities.plugin import PluginEntity +from core.plugin.entities.plugin_daemon import PluginInstallTaskStatus +from core.plugin.impl.exc import PluginDaemonClientSideError, PluginDaemonInternalError +from core.plugin.plugin_service import PluginService +from services.errors.plugin import PluginInstallationForbiddenError + +_IN_FLIGHT: Final = frozenset({PluginInstallTaskStatus.Pending, PluginInstallTaskStatus.Running}) + +_TASK_READ: Final = (*WORKSPACE_READ_GUARDS, CheckPluginInstallSetting()) + + +@contextmanager +def _plugin_errors() -> Generator[None]: + try: + yield + except PluginInstallationForbiddenError as error: + raise PluginInstallForbidden(str(error)) from error + except PluginDaemonClientSideError as error: + raise BadRequest(error.description) from error + except PluginDaemonInternalError as error: + raise PluginServiceUnavailable() from error + except httpx.HTTPError as error: + # The daemon client wraps its own transport errors, so a raw httpx error comes from the marketplace. + raise MarketplaceUnavailable() from error + + +def _require_marketplace() -> None: + if not dify_config.MARKETPLACE_ENABLED: + raise MarketplaceDisabled() + + +def provides_of(plugin: PluginEntity) -> PluginProvides: + declaration = plugin.declaration + return PluginProvides( + model_provider=f"{plugin.plugin_id}/{declaration.model.provider}" if declaration.model else None, + tool_provider=f"{plugin.plugin_id}/{declaration.tool.identity.name}" if declaration.tool else None, + ) + + +def _installed_by_id(workspace_id: str) -> dict[str, PluginEntity]: + with _plugin_errors(): + return {plugin.plugin_id: plugin for plugin in PluginService.list(workspace_id)} + + +def _installed(workspace_id: str, plugin_id: str) -> PluginEntity: + plugin = _installed_by_id(workspace_id).get(plugin_id) + if plugin is None: + raise PluginNotInstalled(f"Plugin {plugin_id} is not installed.") + return plugin + + +def _task_hint(workspace_id: str, task_id: str) -> Hint: + return Hint( + summary="Check again until success or failed", + op=op_of(PluginTaskApi.get), + input={"workspace_id": workspace_id, "task_id": task_id}, + ) + + +def _start_response( + workspace_id: str, task_id: str, all_installed: bool, plugin_ids: Sequence[str] +) -> PluginTaskStartResponse: + hints = _provider_hints(workspace_id, plugin_ids) if all_installed else [_task_hint(workspace_id, task_id)] + return PluginTaskStartResponse(task_id=task_id, all_installed=all_installed, hints=hints) + + +def _plugin_id_of(identifier: str) -> str: + return identifier.split(":", 1)[0] + + +def _provider_hints(workspace_id: str, plugin_ids: Sequence[str]) -> list[Hint]: + installed = _installed_by_id(workspace_id) + hints: list[Hint] = [] + for plugin_id in plugin_ids: + plugin = installed.get(plugin_id) + if plugin is None: + continue + provides = provides_of(plugin) + if provides.model_provider: + hints.append( + Hint( + summary="Set a credential for the new model provider", + op=op_of(ModelProviderApi.get), + input={"workspace_id": workspace_id, "provider": provides.model_provider}, + ) + ) + if provides.tool_provider: + hints.append( + Hint( + summary="Set a credential for the new tools", + op=op_of(ToolProviderApi.get), + input={"workspace_id": workspace_id, "provider": provides.tool_provider}, + ) + ) + return hints + + +@openapi_ns.route("/workspaces//marketplace/plugins") +class MarketplacePluginsApi(Resource): + @endpoint( + op="get.marketplace.plugin", + kind=Kind.LIST, + summary="Search the marketplace for plugins to install; each hit says if it is installed here", + examples=( + Example(title="Find model plugins for OpenAI", input={"query": "openai", "category": "model"}), + Example(title="Most installed tool plugins", input={"category": "tool"}), + ), + requirements=WORKSPACE_READ_GUARDS, + query=MarketplacePluginQuery, + returns=(HTTPStatus.OK, MarketplacePluginListResponse, "Marketplace plugins"), + ) + def get(self, ctx: Context, workspace_id: str, *, query: MarketplacePluginQuery): + _require_marketplace() + try: + page = search_plugins( + query=query.query, + category=query.category.value if query.category else "", + page=query.page, + page_size=query.limit, + ) + except MarketplaceSearchUnavailableError as error: + raise MarketplaceUnavailable() from error + installed = _installed_by_id(ctx.workspace.id) + language = ctx.account.interface_language + rows = [ + MarketplacePluginRow( + plugin_id=item.plugin_id, + identifier=item.latest_package_identifier, + version=item.latest_version, + category=item.category, + label=localized(item.label, language), + brief=localized(item.brief, language), + authorized_category=item.verification.authorized_category if item.verification else None, + install_count=item.install_count, + installed=item.plugin_id in installed, + installed_version=installed[item.plugin_id].version if item.plugin_id in installed else None, + ) + for item in page.plugins + ] + return MarketplacePluginListResponse.build(page=query.page, limit=query.limit, total=page.total, items=rows) + + +@openapi_ns.route("/workspaces//plugins") +class PluginsApi(Resource): + @endpoint( + op="get.plugin", + kind=Kind.OBJECT, + summary="Plugins installed in the workspace, with the provider ids each one adds", + examples=(Example(title="Installed model plugins", input={"category": "model"}),), + requirements=WORKSPACE_READ_GUARDS, + query=PluginListQuery, + returns=(HTTPStatus.OK, PluginListResponse, "Installed plugins"), + ) + def get(self, ctx: Context, workspace_id: str, *, query: PluginListQuery): + with _plugin_errors(): + plugins = [ + plugin + for plugin in PluginService.list(ctx.workspace.id) + if query.category is None or plugin.declaration.category == query.category + ] + latest = PluginService.list_latest_versions([plugin.plugin_id for plugin in plugins]) + language = ctx.account.interface_language + return PluginListResponse( + data=[ + PluginRow( + plugin_id=plugin.plugin_id, + identifier=plugin.plugin_unique_identifier, + version=plugin.version, + latest_version=newest.version if (newest := latest.get(plugin.plugin_id)) else None, + category=str(plugin.declaration.category), + label=localized(plugin.declaration.label, language), + source=str(plugin.source), + provides=provides_of(plugin), + ) + for plugin in plugins + ] + ) + + +@openapi_ns.route("/workspaces//plugins:install") +class PluginInstallApi(Resource): + @endpoint( + op="install.plugin", + kind=Kind.OBJECT, + summary="Start installing marketplace plugins; finishes in the background, check describe.plugin.task", + examples=(Example(title="Install one plugin", input={"identifiers": ["langgenius/openai:0.2.1@"]}),), + requirements=workspace_write_guards( + RBACPermission.PLUGIN_INSTALL, roles=None, extra=(CheckPluginInstallSetting(),) + ), + body=PluginInstallPayload, + returns=(HTTPStatus.OK, PluginTaskStartResponse, "Install started"), + ) + def post(self, ctx: Context, workspace_id: str, *, body: PluginInstallPayload): + _require_marketplace() + with _plugin_errors(): + started = PluginService.install_from_marketplace_pkg(ctx.workspace.id, body.identifiers) + plugin_ids = [_plugin_id_of(identifier) for identifier in body.identifiers] + return _start_response(ctx.workspace.id, started.task_id, started.all_installed, plugin_ids) + + +@openapi_ns.route("/workspaces//plugin-tasks/") +class PluginTaskApi(Resource): + @endpoint( + op="describe.plugin.task", + kind=Kind.OBJECT, + summary="Progress of a plugin install or upgrade; check again while pending or running", + examples=(Example(title="Check an install", input={"task_id": ""}),), + requirements=_TASK_READ, + returns=(HTTPStatus.OK, PluginTaskResponse, "Install task"), + ) + def get(self, ctx: Context, workspace_id: str, task_id: str): + with _plugin_errors(): + task = PluginService.fetch_install_task(ctx.workspace.id, task_id) + if task.status in _IN_FLIGHT: + hints = [_task_hint(ctx.workspace.id, task_id)] + elif task.status == PluginInstallTaskStatus.Success: + hints = _provider_hints(ctx.workspace.id, [plugin.plugin_id for plugin in task.plugins]) + else: + hints = [] + return PluginTaskResponse( + task_id=task_id, + status=task.status, + plugins=[ + PluginTaskItem( + plugin_id=plugin.plugin_id, + identifier=plugin.plugin_unique_identifier, + status=plugin.status, + message=plugin.message, + ) + for plugin in task.plugins + ], + hints=hints, + ) + + +@openapi_ns.route("/workspaces//plugins/") +class PluginApi(Resource): + @endpoint( + op="delete.plugin", + kind=Kind.OBJECT, + summary="Uninstall a plugin from the workspace", + examples=(Example(title="Uninstall OpenAI", input={"plugin_id": "langgenius/openai"}),), + requirements=workspace_write_guards( + RBACPermission.PLUGIN_DELETE, roles=None, extra=(CheckPluginInstallSetting(),) + ), + returns=(HTTPStatus.OK, PluginDeleteResponse, "Plugin uninstalled"), + ) + def delete(self, ctx: Context, workspace_id: str, plugin_id: str): + plugin = _installed(ctx.workspace.id, plugin_id) + with _plugin_errors(): + uninstalled = PluginService.uninstall(ctx.workspace.id, plugin.installation_id) + if not uninstalled: + raise PluginServiceUnavailable() + return PluginDeleteResponse(plugin_id=plugin_id, deleted=True) diff --git a/api/controllers/openapi/tool_providers.py b/api/controllers/openapi/tool_providers.py new file mode 100644 index 00000000000..e1820c06242 --- /dev/null +++ b/api/controllers/openapi/tool_providers.py @@ -0,0 +1,338 @@ +"""Tool provider credentials (API key) and the tools a workspace can use. OAuth credentials need the console.""" + +from __future__ import annotations + +from collections.abc import Callable, Generator, Iterable, Sequence +from contextlib import contextmanager +from http import HTTPStatus +from typing import Final + +from flask_restx import Resource +from werkzeug.exceptions import BadRequest + +from controllers.common.rbac import RBACPermission +from controllers.openapi import openapi_ns +from controllers.openapi._contract import Example, Kind, endpoint, op_of +from controllers.openapi._errors import CredentialNotFound, CredentialOAuthOnly, ProviderNotFound +from controllers.openapi._i18n import localized +from controllers.openapi._models import ( + CredentialFormField, + CredentialRef, + CredentialWriteResponse, + Hint, + ToolCredentialCreatePayload, + ToolCredentialUpdatePayload, + ToolInputValue, + ToolListQuery, + ToolListResponse, + ToolNodeTemplate, + ToolParameterRow, + ToolProviderDetailResponse, + ToolRow, + ToolSource, +) +from controllers.openapi._search import matches +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ADMIN_ROLES, WORKSPACE_READ_GUARDS, workspace_write_guards +from core.db.session_factory import session_factory +from core.entities.provider_entities import ProviderConfig +from core.plugin.entities.plugin_daemon import CredentialType +from core.tools.__base.tool import ToolParameter +from core.tools.builtin_tool.provider import BuiltinToolProviderController +from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity, ToolProviderCredentialApiEntity +from core.tools.errors import ToolProviderNotFoundError +from core.tools.plugin_tool.provider import PluginToolProviderController +from core.tools.tool_manager import ToolManager +from extensions.ext_application_services import application_services +from services.tools.api_tools_manage_service import ApiToolManageService +from services.tools.builtin_tools_manage_service import BuiltinToolManageService +from services.tools.mcp_tools_manage_service import MCPToolManageService +from services.tools.workflow_tools_manage_service import WorkflowToolManageService + +_PROVIDER_EXAMPLE: Final = "langgenius/tavily/tavily" +_ToolProviderController = BuiltinToolProviderController | PluginToolProviderController +_PROVIDER_PATH: Final = "/workspaces//tool-providers/" + + +def _form(schemas: Sequence[ProviderConfig], language: str | None) -> list[CredentialFormField]: + return [ + CredentialFormField.of( + name=field.name, + type=field.type, + required=field.required, + label=field.label, + placeholder=field.placeholder, + options=field.options, + language=language, + ) + for field in schemas + ] + + +@contextmanager +def _provider_not_found_errors() -> Generator[None]: + try: + yield + except ToolProviderNotFoundError as error: + raise ProviderNotFound(str(error)) from error + + +@contextmanager +def _credential_write_errors() -> Generator[None]: + """Maps what the builtin tool service raises for a credential write; it wraps every failure, a rejected key + included, in a ValueError.""" + try: + yield + except ValueError as error: + raise BadRequest(str(error)) from error + + +def _controller(workspace_id: str, provider: str) -> _ToolProviderController: + with _provider_not_found_errors(): + return ToolManager.get_builtin_provider(provider, workspace_id) + + +def _api_key_supported(controller: _ToolProviderController) -> bool: + return CredentialType.API_KEY in controller.get_supported_credential_types() + + +def _credentials(ctx: Context, provider: str) -> list[ToolProviderCredentialApiEntity]: + with _provider_not_found_errors(): + return BuiltinToolManageService.get_builtin_tool_provider_credentials( + tenant_id=ctx.workspace.id, + provider_name=provider, + user=ctx.account, + credential_query=application_services().credential_queries, + ) + + +def _visible_credential(ctx: Context, provider: str, credential_id: str) -> ToolProviderCredentialApiEntity: + for credential in _credentials(ctx, provider): + if credential.id == credential_id: + return credential + raise CredentialNotFound() + + +def _write_response(ctx: Context, provider: str, credential_id: str) -> CredentialWriteResponse: + credential = _visible_credential(ctx, provider, credential_id) + return CredentialWriteResponse(id=credential.id, name=credential.name, active=credential.is_default) + + +def _mcp_providers(ctx: Context) -> list[ToolProviderApiEntity]: + with session_factory.create_session() as session: + return MCPToolManageService(session=session).list_providers(tenant_id=ctx.workspace.id, include_sensitive=False) + + +_TOOL_SOURCES: Final[dict[ToolSource, Callable[[Context], list[ToolProviderApiEntity]]]] = { + ToolSource.BUILTIN: lambda ctx: BuiltinToolManageService.list_builtin_tools(ctx.account.id, ctx.workspace.id), + ToolSource.WORKFLOW: lambda ctx: WorkflowToolManageService.list_tenant_workflow_tools( + ctx.account.id, ctx.workspace.id + ), + ToolSource.API: lambda ctx: ApiToolManageService.list_api_tools(ctx.workspace.id), + ToolSource.MCP: _mcp_providers, +} + + +def _inputs(parameters: Sequence[ToolParameter], form: ToolParameter.ToolParameterForm) -> dict[str, ToolInputValue]: + return {p.name: ToolInputValue(value=p.default) for p in parameters if p.form == form} + + +def _parameter_row(parameter: ToolParameter, language: str | None) -> ToolParameterRow: + return ToolParameterRow( + name=parameter.name, + label=localized(parameter.label, language), + type=str(parameter.type), + form=str(parameter.form), + required=parameter.required, + default=parameter.default, + options=[option.value for option in parameter.options], + min=parameter.min, + max=parameter.max, + llm_description=parameter.llm_description, + ) + + +def tool_row(provider: ToolProviderApiEntity, tool: ToolApiEntity, language: str | None) -> ToolRow: + parameters = tool.parameters or [] + label = localized(tool.label, language) or tool.name + return ToolRow( + provider=provider.id, + provider_type=str(provider.type), + provider_label=localized(provider.label, language), + configured=provider.is_team_authorization, + name=tool.name, + label=label, + description=localized(tool.description, language), + parameters=[_parameter_row(p, language) for p in parameters], + node_data=ToolNodeTemplate( + title=label, + provider_type=str(provider.type), + provider_id=provider.server_identifier or provider.id, + provider_name=provider.name, + plugin_id=provider.plugin_id or None, + plugin_unique_identifier=provider.plugin_unique_identifier or None, + tool_name=tool.name, + tool_label=label, + tool_parameters=_inputs(parameters, ToolParameter.ToolParameterForm.LLM), + tool_configurations=_inputs(parameters, ToolParameter.ToolParameterForm.FORM), + ), + ) + + +def tool_rows( + providers: Iterable[ToolProviderApiEntity], *, words: str, provider: str | None, language: str | None +) -> list[ToolRow]: + rows = [tool_row(p, tool, language) for p in providers if provider is None or p.id == provider for tool in p.tools] + rows = [r for r in rows if matches(words, r.name, r.label, r.provider, r.provider_label)] + return sorted(rows, key=lambda r: (r.provider, r.name)) + + +@openapi_ns.route("/workspaces//tools") +class ToolsApi(Resource): + @endpoint( + op="get.tool", + kind=Kind.LIST, + summary="Tools a workflow can call here, each with a ready tool node data", + examples=( + Example(title="Find GitHub tools", input={"query": "github"}), + Example(title="Workflows published as tools", input={"provider_type": "workflow"}), + ), + requirements=WORKSPACE_READ_GUARDS, + query=ToolListQuery, + returns=(HTTPStatus.OK, ToolListResponse, "Tools"), + ) + def get(self, ctx: Context, workspace_id: str, *, query: ToolListQuery): + sources = [query.provider_type] if query.provider_type else list(ToolSource) + providers = [p for source in sources for p in _TOOL_SOURCES[source](ctx)] + rows = tool_rows(providers, words=query.query, provider=query.provider, language=ctx.account.interface_language) + page = ToolListResponse.page_of(rows, query=query) + unready = sorted({r.provider for r in page.data if not r.configured and r.provider_type == ToolSource.BUILTIN}) + page.hints = [ + Hint( + summary="Set up this provider to use its tools", + op=op_of(ToolProviderApi.get), + input={"workspace_id": ctx.workspace.id, "provider": provider}, + ) + for provider in unready + ] + return page + + +@openapi_ns.route(_PROVIDER_PATH) +class ToolProviderApi(Resource): + @endpoint( + op="describe.tool_provider", + kind=Kind.OBJECT, + summary="One tool provider with its API-key form and saved credentials (names only)", + examples=(Example(title="Tavily", input={"provider": _PROVIDER_EXAMPLE}),), + requirements=WORKSPACE_READ_GUARDS, + returns=(HTTPStatus.OK, ToolProviderDetailResponse, "Tool provider"), + ) + def get(self, ctx: Context, workspace_id: str, provider: str): + controller = _controller(ctx.workspace.id, provider) + language = ctx.account.interface_language + api_key = _api_key_supported(controller) + form = _form(controller.get_credentials_schema_by_type(CredentialType.API_KEY), language) if api_key else [] + saved = _credentials(ctx, provider) + configured = ( + not controller.need_credentials + or BuiltinToolManageService.get_builtin_provider(provider, ctx.workspace.id) is not None + ) + default = next((c for c in saved if c.is_default), None) + hints = ( + [ + Hint( + summary="Ask the user for these values, then save them", + op=op_of(ToolProviderCredentialsApi.post), + input={"workspace_id": ctx.workspace.id, "provider": provider, "credentials": None}, + form=[field.model_dump() for field in form], + ) + ] + if api_key and form and not configured + else [] + ) + hints.append( + Hint( + summary="List its tools", + op=op_of(ToolsApi.get), + input={"workspace_id": ctx.workspace.id, "provider": provider}, + ) + ) + return ToolProviderDetailResponse( + provider=provider, + label=localized(controller.entity.identity.label, language), + configured=configured, + credential_types=[str(t) for t in controller.get_supported_credential_types()], + credential_form=form, + credentials=[CredentialRef(id=c.id, name=c.name) for c in saved], + default_credential=CredentialRef(id=default.id, name=default.name) if default else None, + hints=hints, + ) + + +@openapi_ns.route(f"{_PROVIDER_PATH}/credentials") +class ToolProviderCredentialsApi(Resource): + @endpoint( + op="create.tool_provider.credential", + kind=Kind.OBJECT, + summary="Save an API-key credential for a tool provider; the provider checks it first", + examples=( + Example( + title="Save a Tavily key (pass credentials with @-)", + input={"provider": _PROVIDER_EXAMPLE, "credentials": {"tavily_api_key": ""}}, + ), + ), + requirements=workspace_write_guards(RBACPermission.CREDENTIAL_CREATE, roles=ADMIN_ROLES), + body=ToolCredentialCreatePayload, + returns=(HTTPStatus.CREATED, CredentialWriteResponse, "Credential saved"), + ) + def post(self, ctx: Context, workspace_id: str, provider: str, *, body: ToolCredentialCreatePayload): + if not _api_key_supported(_controller(ctx.workspace.id, provider)): + raise CredentialOAuthOnly() + with _credential_write_errors(): + saved = BuiltinToolManageService.add_builtin_tool_provider( + user_id=ctx.account.id, + tenant_id=ctx.workspace.id, + provider=provider, + credentials=body.credentials, + name=body.name, + api_type=CredentialType.API_KEY, + ) + return _write_response(ctx, provider, saved["id"]), HTTPStatus.CREATED + + +@openapi_ns.route(f"{_PROVIDER_PATH}/credentials/") +class ToolProviderCredentialApi(Resource): + @endpoint( + op="set.tool_provider.credential", + kind=Kind.OBJECT, + summary="Replace a saved tool provider credential", + examples=( + Example( + title="Rotate the key", + input={ + "provider": _PROVIDER_EXAMPLE, + "credential_id": "", + "credentials": {"tavily_api_key": ""}, + }, + ), + ), + requirements=workspace_write_guards(RBACPermission.CREDENTIAL_MANAGE, roles=ADMIN_ROLES), + body=ToolCredentialUpdatePayload, + returns=(HTTPStatus.OK, CredentialWriteResponse, "Credential replaced"), + ) + def patch( + self, ctx: Context, workspace_id: str, provider: str, credential_id: str, *, body: ToolCredentialUpdatePayload + ): + if _visible_credential(ctx, provider, credential_id).credential_type != CredentialType.API_KEY: + raise CredentialOAuthOnly() + with _credential_write_errors(): + BuiltinToolManageService.update_builtin_tool_provider( + user_id=ctx.account.id, + tenant_id=ctx.workspace.id, + provider=provider, + credential_id=credential_id, + credentials=body.credentials, + name=body.name, + ) + return _write_response(ctx, provider, credential_id) diff --git a/api/controllers/openapi/workflow_events.py b/api/controllers/openapi/workflow_events.py index a63303263de..1dbbcd5cc96 100644 --- a/api/controllers/openapi/workflow_events.py +++ b/api/controllers/openapi/workflow_events.py @@ -55,12 +55,15 @@ class OpenApiWorkflowEventsApi(Resource): @endpoint( op="get.run.event", kind=Kind.SSE, - summary="Stream the events of a workflow run", + summary="Stream the events of a workflow run; `task_id` takes the workflow run id", examples=( - Example(title="Follow the events of a running task", input={"app_id": "", "task_id": ""}), + Example( + title="Follow the events of a workflow run by its run id", + input={"app_id": "", "task_id": ""}, + ), Example( title="Keep the stream open across a human-input pause", - input={"app_id": "", "task_id": "", "continue_on_pause": True}, + input={"app_id": "", "task_id": "", "continue_on_pause": True}, ), ), requirements=( diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index f16930c5565..24b30c09128 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -57,7 +57,6 @@ from services.errors.workspace import ( InvalidWorkspaceMemberRoleError, MemberNotInTenantError, RoleAlreadyAssignedError, - WorkspaceNotLinkedError, ) from services.workspace.contracts import WorkspaceInvitation, WorkspaceMemberRecord, WorkspaceSnapshot @@ -87,9 +86,7 @@ class WorkspacesApi(Resource): def get(self, ctx: Context, *, query: WorkspaceListQuery): if isinstance(ctx.subject, ResourceAccessSubject): tenant = ctx.workspace - item = WorkspaceSummaryResponse( - id=str(tenant.id), name=tenant.name, role="", status=tenant.status, current=True - ) + item = WorkspaceSummaryResponse(id=str(tenant.id), name=tenant.name, role="", status=tenant.status) return WorkspaceListResponse.page_of([item], query=query) rows = application_services().workspaces.management.list_memberships(str(ctx.subject.account_id)) return WorkspaceListResponse.page_of([_workspace_summary(row) for row in rows], query=query) @@ -116,38 +113,6 @@ class WorkspaceByIdApi(Resource): return _workspace_detail(row) -@openapi_ns.route("/workspaces/:switch") -class WorkspaceSwitchApi(Resource): - """Server-side switch — equivalent to the console's POST /workspaces/switch. - - CLI `difyctl use workspace ` calls this; it does NOT mutate - ``hosts.yml`` on its own. Failure here must abort the local write so - that ``hosts.yml`` never diverges from the server's ``current`` state. - """ - - @endpoint( - op="switch.workspace", - kind=Kind.OBJECT, - summary="Server-side current workspace switch (shared with the web console)", - examples=(Example(title="Make a workspace current on the server", input={"workspace_id": ""}),), - internal=True, - requirements=( - CheckSubject(allowed=(AccountSubject,)), - CheckScope(Scope.WORKSPACE_READ), - CheckWorkspaceMember(), - ), - returns=(200, WorkspaceDetailResponse, "Workspace detail"), - ) - def post(self, ctx: Context, workspace_id: str): - try: - workspace = application_services().workspaces.management.switch_membership( - str(ctx.subject.account_id), workspace_id - ) - except WorkspaceNotLinkedError: - raise NotFound("workspace not found") from None - return _workspace_detail(workspace) - - @openapi_ns.route("/workspaces//members") class WorkspaceMembersApi(Resource): """List + invite members. @@ -325,7 +290,6 @@ def _workspace_summary(tenant: WorkspaceSnapshot) -> WorkspaceSummaryResponse: name=tenant.name, role=tenant.role or "", status=tenant.status, - current=tenant.current, ) @@ -335,6 +299,5 @@ def _workspace_detail(tenant: WorkspaceSnapshot) -> WorkspaceDetailResponse: name=tenant.name, role=tenant.role or "", status=tenant.status, - current=tenant.current, created_at=tenant.created_at.isoformat() if tenant.created_at else None, ) diff --git a/api/core/entities/execution_extra_content.py b/api/core/entities/execution_extra_content.py index 5969f103e07..355ce4a46d7 100644 --- a/api/core/entities/execution_extra_content.py +++ b/api/core/entities/execution_extra_content.py @@ -20,7 +20,7 @@ class HumanInputFormDefinition(BaseModel): actions: Sequence[UserActionConfig] = Field(default_factory=list) display_in_ui: bool = False - # `form_token` is `None` if the corresponding form has been submitted. + # `form_token` is `None` unless the corresponding form is still waiting for an answer. form_token: str | None = None resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) expiration_time: int diff --git a/api/core/entities/provider_configuration.py b/api/core/entities/provider_configuration.py index befb95200fd..830737a5253 100644 --- a/api/core/entities/provider_configuration.py +++ b/api/core/entities/provider_configuration.py @@ -536,7 +536,7 @@ class ProviderConfiguration(BaseModel): ProviderManager.invalidate_configurations_cache(self.tenant_id, sources=sources) - def create_provider_credential(self, credentials: dict[str, Any], credential_name: str | None): + def create_provider_credential(self, credentials: dict[str, Any], credential_name: str | None) -> str: """ Add custom provider credentials. :param credentials: provider credentials @@ -564,6 +564,7 @@ class ProviderConfiguration(BaseModel): ) session.add(new_record) session.flush() + new_credential_id = new_record.id if not provider_record: provider_record = Provider( @@ -611,6 +612,7 @@ class ProviderConfiguration(BaseModel): preferred_model_providers=preferred_model_providers_changed, provider_credentials=True, ) + return new_credential_id def update_provider_credential( self, @@ -1051,7 +1053,7 @@ class ProviderConfiguration(BaseModel): def create_custom_model_credential( self, model_type: ModelType, model: str, credentials: dict[str, Any], credential_name: str | None - ) -> None: + ) -> str: """ Create a custom model credential. @@ -1089,6 +1091,7 @@ class ProviderConfiguration(BaseModel): ) session.add(credential) session.flush() + new_credential_id = credential.id if not provider_model_record: provider_model_record = ProviderModel( @@ -1116,6 +1119,7 @@ class ProviderConfiguration(BaseModel): provider_models=True, provider_model_credentials=True, ) + return new_credential_id def update_custom_model_credential( self, diff --git a/api/core/helper/marketplace.py b/api/core/helper/marketplace.py index b45f14e7215..7ad3e083899 100644 --- a/api/core/helper/marketplace.py +++ b/api/core/helper/marketplace.py @@ -4,6 +4,7 @@ from typing import Any from urllib.parse import urlencode import httpx +from pydantic import BaseModel, ConfigDict from yarl import URL from configs import dify_config @@ -68,6 +69,70 @@ def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict[str, Any]]: return result +class MarketplaceVerification(BaseModel): + model_config = ConfigDict(extra="ignore") + + authorized_category: str + + +class MarketplaceSearchItem(BaseModel): + """The fields read from one marketplace search hit; the rest are ignored.""" + + model_config = ConfigDict(extra="ignore") + + plugin_id: str + latest_version: str + latest_package_identifier: str + category: str + label: dict[str, str] + brief: dict[str, str] + verification: MarketplaceVerification | None = None + install_count: int + + +class MarketplaceSearchPage(BaseModel): + model_config = ConfigDict(extra="ignore") + + plugins: list[MarketplaceSearchItem] + total: int + + +class _MarketplaceSearchResponse(BaseModel): + model_config = ConfigDict(extra="ignore") + + data: MarketplaceSearchPage + + +class MarketplaceSearchUnavailableError(Exception): + """The marketplace could not be reached or answered with something other than a search page.""" + + +def search_plugins(*, query: str, category: str, page: int, page_size: int) -> MarketplaceSearchPage: + """The marketplace's own search, with the body the web plugin page sends.""" + url = str(marketplace_api_url / "api/v1/plugins/search/advanced") + try: + response = httpx.post( + url, + json={ + "page": page, + "page_size": page_size, + "query": query, + "category": category, + "sort_by": "install_count", + "sort_order": "DESC", + "tags": [], + "exclude": [], + "type": "plugin", + }, + headers={"X-Dify-Version": dify_config.project.version}, + timeout=MARKETPLACE_TIMEOUT, + ) + response.raise_for_status() + return _MarketplaceSearchResponse.model_validate_json(response.content).data + except (httpx.HTTPError, ValueError) as error: + raise MarketplaceSearchUnavailableError(str(error)) from error + + def record_install_plugin_event(plugin_unique_identifier: str) -> None: url = str(marketplace_api_url / "api/v1/stats/plugins/install_count") response = httpx.post(url, json={"unique_identifier": plugin_unique_identifier}, timeout=MARKETPLACE_TIMEOUT) diff --git a/api/core/workflow/variable_prefixes.py b/api/core/workflow/variable_prefixes.py index 7664be09830..18eb4db349f 100644 --- a/api/core/workflow/variable_prefixes.py +++ b/api/core/workflow/variable_prefixes.py @@ -2,3 +2,8 @@ SYSTEM_VARIABLE_NODE_ID = "sys" ENVIRONMENT_VARIABLE_NODE_ID = "env" CONVERSATION_VARIABLE_NODE_ID = "conversation" RAG_PIPELINE_VARIABLE_NODE_ID = "rag" + +# Selector heads a workflow graph reads without a node of that id. +ROOT_VARIABLE_NODE_IDS = frozenset( + {SYSTEM_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID} +) diff --git a/api/extensions/application_services/app.py b/api/extensions/application_services/app.py index d7b62e352ec..05f2d03133c 100644 --- a/api/extensions/application_services/app.py +++ b/api/extensions/application_services/app.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session, sessionmaker from repositories.account.repository import SQLAlchemyAccountRepository from repositories.app.api_key_repository import AppApiKeyRepository from repositories.app.console_repository import ConsoleAppRepository +from repositories.app.dsl_import_repository import DslImportRepository from repositories.app.export_repository import AppExportRepository from repositories.workspace.workspace_repository import WorkspaceRepository from services.agent.roster_package_exporter import RosterAgentPackageExporter @@ -26,12 +27,14 @@ from services.app_tracing_config_gateway import OpsTraceManagerGateway from services.oauth_server_service import OAuthServerService from services.rbac.members import MemberService from services.recommended_app_package_service import RecommendedAppPackageService +from services.workflow.dsl_import import DslImportService @dataclass(frozen=True, slots=True) class AppServices: imports: AppImportService exports: AppExportService + dsl_imports: DslImportService discovery: AppDiscoveryService console: ConsoleAppService @@ -57,6 +60,7 @@ def build_app_services( exports=AppExportService( apps=AppExportRepository(session_factory=database_client), serialize=AppDslService.serialize_export_data ), + dsl_imports=DslImportService(store=DslImportRepository(session_factory=database_client)), discovery=AppDiscoveryService( apps=repository, workspaces=WorkspaceRepository(database_client), diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 7e7b8090213..4ffaeee4227 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -1775,6 +1775,7 @@ Create a new application | ---- | ----------- | ------ | | 200 | Import confirmed | **application/json**: [Import](#import)
| | 400 | Import failed | **application/json**: [Import](#import)
[ErrorResponse](#errorresponse)
| +| 409 | The draft changed after the export the import is based on | **application/json**: [ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /apps/recent diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index 7b071d31b37..b05a1b31cd9 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -91,7 +91,7 @@ Machine-readable catalog of every op on this surface | ---- | ---------- | ----------- | -------- | ------ | | limit | query | | No | integer,
**Default:** 20 | | mode | query | App types the ``app`` usage face (``get app``) lists and filters. A curated subset of :class:`AppMode`: the real, user-facing app categories. Excludes runtime-only mode tags that are not standalone apps (``rag-pipeline`` is a knowledge ``Pipeline``; ``channel`` is unused) and the roster-owned ``agent`` type (surfaced through the roster, not this list). Members reference ``AppMode.*.value`` so the subset relationship is type-checked: dropping a member from ``AppMode`` breaks this at import. This is the single source for the listable set — params, filters, and the generated CLI whitelist all derive from it. | No | string,
**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | -| name | query | | No | string | +| name | query | Filter by a name substring; an app id returns that one app and ignores `mode` | No | string | | page | query | | No | integer,
**Default:** 1 | | workspace_id | query | | Yes | string (uuid) | @@ -140,6 +140,216 @@ Machine-readable catalog of every op on this surface | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| +### [GET] /apps/{app_id}/app-info/advanced-chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [ChatAppInfo](#chatappinfo)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/app-info/advanced-chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatAppInfoPatch](#chatappinfopatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [ChatAppInfo](#chatappinfo)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/app-info/agent +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [AgentAppInfo](#agentappinfo)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/app-info/agent +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentAppInfoPatch](#agentappinfopatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [AgentAppInfo](#agentappinfo)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/app-info/agent-chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [ChatAppInfo](#chatappinfo)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/app-info/agent-chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatAppInfoPatch](#chatappinfopatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [ChatAppInfo](#chatappinfo)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/app-info/chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [ChatAppInfo](#chatappinfo)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/app-info/chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatAppInfoPatch](#chatappinfopatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [ChatAppInfo](#chatappinfo)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/app-info/completion +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [AppSettingsInfo](#appsettingsinfo)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/app-info/completion +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppInfoPatch](#appinfopatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [AppSettingsInfo](#appsettingsinfo)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/app-info/workflow +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [AppSettingsInfo](#appsettingsinfo)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/app-info/workflow +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppInfoPatch](#appinfopatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App info | **application/json**: [AppSettingsInfo](#appsettingsinfo)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + ### [POST] /apps/{app_id}/chat:run #### Parameters @@ -193,7 +403,93 @@ Machine-readable catalog of every op on this surface | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)
| +| 200 | Dependencies checked | **application/json**: [CheckDependenciesResponse](#checkdependenciesresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /apps/{app_id}/draft/advanced-chat/nodes/{node_id}:run +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AdvancedChatNodeRunPayload](#advancedchatnoderunpayload)
**multipart/form-data**: [AdvancedChatNodeRunPayload](#advancedchatnoderunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node execution | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /apps/{app_id}/draft/advanced-chat:run +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatRunPayload](#chatrunpayload)
**multipart/form-data**: [ChatRunPayload](#chatrunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /apps/{app_id}/draft/workflow/nodes/{node_id}:run +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [NodeRunPayload](#noderunpayload)
**multipart/form-data**: [NodeRunPayload](#noderunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node execution | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /apps/{app_id}/draft/workflow:run +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DraftWorkflowRunPayload](#draftworkflowrunpayload)
**multipart/form-data**: [DraftWorkflowRunPayload](#draftworkflowrunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| ### [GET] /apps/{app_id}/dsl @@ -201,7 +497,7 @@ Machine-readable catalog of every op on this surface | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| include_secret | query | Include encrypted secret values in the exported DSL | No | boolean | +| include_secret | query | Include secret values in plaintext in the exported DSL | No | boolean | | workflow_id | query | Export a specific workflow version instead of the current draft | No | string (uuid) | | app_id | path | | Yes | string | @@ -213,6 +509,57 @@ Machine-readable catalog of every op on this surface | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| +### [GET] /apps/{app_id}/env +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Environment variables | **application/json**: [EnvVariableListResponse](#envvariablelistresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [DELETE] /apps/{app_id}/env/{env_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| env_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable removed | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PUT] /apps/{app_id}/env/{env_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| env_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EnvVariableSetPayload](#envvariablesetpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable set | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + ### [POST] /apps/{app_id}/files Upload a file to use as an input variable when running the app @@ -277,6 +624,139 @@ Upload a file to use as an input variable when running the app | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| +### [GET] /apps/{app_id}/release:check +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Release check | **application/json**: [ReleaseCheckResponse](#releasecheckresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/runs +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| last_id | query | Cursor: id of the last run on the previous page | No | string (uuid) | +| limit | query | | No | integer,
**Default:** 20 | +| status | query | | No | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | +| triggered_from | query | debugging: draft test runs, as in the console; app-run: real use | No | string,
**Available values:** "app-run", "debugging",
**Default:** debugging | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Run list | **application/json**: [RunListResponse](#runlistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/runs/{run_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| run_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Run detail | **application/json**: [WorkflowRunDetailResponse](#workflowrundetailresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/runs/{run_id}/nodes +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| run_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node steps | **application/json**: [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/service-api +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Service API | **application/json**: [ServiceApi](#serviceapi)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/service-api +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ServiceApiPatch](#serviceapipatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Service API | **application/json**: [ServiceApi](#serviceapi)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/service-api/agent +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Service API | **application/json**: [AgentServiceApi](#agentserviceapi)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/service-api/agent +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ServiceApiPatch](#serviceapipatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Service API | **application/json**: [AgentServiceApi](#agentserviceapi)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + ### [GET] /apps/{app_id}/tasks/{task_id}/events #### Parameters @@ -310,6 +790,347 @@ Upload a file to use as an input variable when running the app | 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| +### [GET] /apps/{app_id}/versions +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | | No | integer,
**Default:** 20 | +| named_only | query | Only versions that have a name | No | boolean | +| page | query | | No | integer,
**Default:** 1 | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Version list | **application/json**: [VersionListResponse](#versionlistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /apps/{app_id}/versions/{version_id}:restore +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| version_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Restored into the draft | **application/json**: [RestoreResponse](#restoreresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/webapp-access +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web-app access | **application/json**: [WebAppAccess](#webappaccess)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PUT] /apps/{app_id}/webapp-access +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WebAppAccessPayload](#webappaccesspayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web-app access | **application/json**: [WebAppAccess](#webappaccess)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/webapp-access/agent +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web-app access | **application/json**: [WebAppAccess](#webappaccess)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PUT] /apps/{app_id}/webapp-access/agent +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WebAppAccessPayload](#webappaccesspayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web-app access | **application/json**: [WebAppAccess](#webappaccess)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/webapp/advanced-chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [AdvancedChatWebApp](#advancedchatwebapp)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/webapp/advanced-chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AdvancedChatWebAppPatch](#advancedchatwebapppatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [AdvancedChatWebApp](#advancedchatwebapp)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/webapp/agent +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [AgentWebApp](#agentwebapp)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/webapp/agent +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatWebAppPatch](#chatwebapppatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [AgentWebApp](#agentwebapp)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/webapp/agent-chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [ChatWebApp](#chatwebapp)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/webapp/agent-chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatWebAppPatch](#chatwebapppatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [ChatWebApp](#chatwebapp)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /apps/{app_id}/webapp/agent:reset +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | New URL token | **application/json**: [WebAppToken](#webapptoken)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/webapp/chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [ChatWebApp](#chatwebapp)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/webapp/chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatWebAppPatch](#chatwebapppatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [ChatWebApp](#chatwebapp)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/webapp/completion +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [WebApp](#webapp)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/webapp/completion +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WebAppPatch](#webapppatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [WebApp](#webapp)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/webapp/workflow +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [WorkflowWebApp](#workflowwebapp)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /apps/{app_id}/webapp/workflow +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowWebAppPatch](#workflowwebapppatch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Web app | **application/json**: [WorkflowWebApp](#workflowwebapp)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /apps/{app_id}/webapp:reset +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | New URL token | **application/json**: [WebAppToken](#webapptoken)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + ### [POST] /apps/{app_id}/workflow:run #### Parameters @@ -331,6 +1152,49 @@ Upload a file to use as an input variable when running the app | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| +### [POST] /apps/{app_id}:publish +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [PublishPayload](#publishpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Published | **application/json**: [PublishResponse](#publishresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /node-types +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node types | **application/json**: [NodeTypeListResponse](#nodetypelistresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /node-types/{node_type} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_type | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node type | **application/json**: [NodeTypeDetailResponse](#nodetypedetailresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + ### [POST] /oauth/device/approve #### Request Body @@ -473,6 +1337,27 @@ Upload a file to use as an input variable when running the app | 200 | Workspace detail | **application/json**: [WorkspaceDetailResponse](#workspacedetailresponse)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| +### [POST] /workspaces/{workspace_id}/apps/advanced-chat +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| workspace_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CreateAppPayload](#createapppayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Created | **application/json**: [CreatedAppResponse](#createdappresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + ### [POST] /workspaces/{workspace_id}/apps/imports #### Parameters @@ -512,6 +1397,85 @@ Upload a file to use as an input variable when running the app | 400 | Import failed | **application/json**: [Import](#import)
[ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| +### [POST] /workspaces/{workspace_id}/apps/imports:check +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| workspace_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DslCheckPayload](#dslcheckpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Check result | **application/json**: [DslCheckResponse](#dslcheckresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /workspaces/{workspace_id}/apps/workflow +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| workspace_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CreateAppPayload](#createapppayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Created | **application/json**: [CreatedAppResponse](#createdappresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /workspaces/{workspace_id}/knowledge-bases +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | +| query | query | Words to match in the knowledge base name | No | string | +| workspace_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Knowledge bases | **application/json**: [KnowledgeBaseListResponse](#knowledgebaselistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /workspaces/{workspace_id}/marketplace/plugins +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| category | query | Only plugins of this category | No | string,
**Available values:** "agent-strategy", "datasource", "extension", "model", "tool", "trigger" | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | +| query | query | Words to search for; empty lists the most installed plugins | No | string | +| workspace_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Marketplace plugins | **application/json**: [MarketplacePluginListResponse](#marketplacepluginlistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + ### [GET] /workspaces/{workspace_id}/members #### Parameters @@ -587,18 +1551,276 @@ Upload a file to use as an input variable when running the app | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| -### [POST] /workspaces/{workspace_id}:switch +### [GET] /workspaces/{workspace_id}/model-providers/{provider} #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | | workspace_id | path | | Yes | string | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workspace detail | **application/json**: [WorkspaceDetailResponse](#workspacedetailresponse)
| +| 200 | Model provider | **application/json**: [ModelProviderDetailResponse](#modelproviderdetailresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /workspaces/{workspace_id}/model-providers/{provider}/credentials +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| workspace_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ProviderCredentialCreatePayload](#providercredentialcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Credential saved | **application/json**: [CredentialWriteResponse](#credentialwriteresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /workspaces/{workspace_id}/model-providers/{provider}/credentials/{credential_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| credential_id | path | | Yes | string | +| provider | path | | Yes | string | +| workspace_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ProviderCredentialUpdatePayload](#providercredentialupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Credential replaced | **application/json**: [CredentialWriteResponse](#credentialwriteresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /workspaces/{workspace_id}/model-providers/{provider}/models/credentials +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| workspace_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ModelCredentialCreatePayload](#modelcredentialcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Credential saved | **application/json**: [CredentialWriteResponse](#credentialwriteresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /workspaces/{workspace_id}/model-providers/{provider}/models/credentials/{credential_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| credential_id | path | | Yes | string | +| provider | path | | Yes | string | +| workspace_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ModelCredentialUpdatePayload](#modelcredentialupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Credential replaced | **application/json**: [CredentialWriteResponse](#credentialwriteresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /workspaces/{workspace_id}/models +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | | No | integer,
**Default:** 20 | +| model_type | query | Only models of this type | No | string,
**Available values:** "llm", "moderation", "rerank", "speech2text", "text-embedding", "tts" | +| page | query | | No | integer,
**Default:** 1 | +| provider | query | Only this provider's models, such as langgenius/openai/openai | No | string | +| query | query | Words to match in the model name, label or provider | No | string | +| workspace_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Models | **application/json**: [ModelListResponse](#modellistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /workspaces/{workspace_id}/plugin-tasks/{task_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | | Yes | string | +| workspace_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Install task | **application/json**: [PluginTaskResponse](#plugintaskresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /workspaces/{workspace_id}/plugins +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| category | query | Only plugins of this category | No | string,
**Available values:** "agent-strategy", "datasource", "extension", "model", "tool", "trigger" | +| workspace_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Installed plugins | **application/json**: [PluginListResponse](#pluginlistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [DELETE] /workspaces/{workspace_id}/plugins/{plugin_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| plugin_id | path | | Yes | string | +| workspace_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Plugin uninstalled | **application/json**: [PluginDeleteResponse](#plugindeleteresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /workspaces/{workspace_id}/plugins:install +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| workspace_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [PluginInstallPayload](#plugininstallpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Install started | **application/json**: [PluginTaskStartResponse](#plugintaskstartresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /workspaces/{workspace_id}/tool-providers/{provider} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| workspace_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Tool provider | **application/json**: [ToolProviderDetailResponse](#toolproviderdetailresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /workspaces/{workspace_id}/tool-providers/{provider}/credentials +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| workspace_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ToolCredentialCreatePayload](#toolcredentialcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Credential saved | **application/json**: [CredentialWriteResponse](#credentialwriteresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [PATCH] /workspaces/{workspace_id}/tool-providers/{provider}/credentials/{credential_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| credential_id | path | | Yes | string | +| provider | path | | Yes | string | +| workspace_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ToolCredentialUpdatePayload](#toolcredentialupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Credential replaced | **application/json**: [CredentialWriteResponse](#credentialwriteresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /workspaces/{workspace_id}/tools +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | +| provider | query | Only this provider's tools | No | string | +| provider_type | query | Only tools of this provider type | No | string,
**Available values:** "api", "builtin", "mcp", "workflow" | +| query | query | Words to match in the tool name or label, or the provider id or label | No | string | +| workspace_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Tools | **application/json**: [ToolListResponse](#toollistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| --- @@ -623,28 +1845,138 @@ Upload a file to use as an input variable when running the app | subject_type | [SubjectType](#subjecttype) | | Yes | | workspaces | [ [WorkspacePayload](#workspacepayload) ],
**Default:** | | No | +#### AdvancedChatNodeRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | object | Local file paths keyed by the file variable name (a start variable or #node.var#); each is uploaded | No | +| inputs | object | Overrides for what the last draft run saved, keyed by variable reference such as #llm.text# | No | +| query | string | The user message the node sees as sys.query | No | + #### AdvancedChatRunPayload A chat run against an advanced-chat (chatflow) app, which can also pin a workflow version. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No | +| attachments | [ string ] | Local file paths attached to the run itself (the app's `sys.files`), not to a variable | No | | auto_generate_name | boolean,
**Default:** true | Let the server name a new conversation | No | | conversation_id | string | Continue an existing conversation | No | -| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[]`. Send a list (part name `files[][]`) for a file-list variable | No | -| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes | +| files | object | Local file paths keyed by the app's file variable name; each file is uploaded and becomes that variable's value. Give a list of paths for a file-list variable | No | +| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local path in `files`, not both. | No | | query | string | User message | Yes | | workflow_id | string | Pin a published workflow version | No | | workspace_id | string | Workspace that owns the app | No | +#### AdvancedChatWebApp + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | No | +| app_base_url | string | | Yes | +| chat_color_theme | string | | No | +| chat_color_theme_inverted | boolean | | No | +| copyright | string | | No | +| custom_disclaimer | string | | No | +| default_language | string | | No | +| description | string | | No | +| enabled | boolean | | Yes | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| input_placeholder | string | | No | +| privacy_policy | string | | No | +| show_workflow_steps | boolean | | No | +| title | string | | No | +| url | string | | No | +| use_icon_as_answer_icon | boolean | | No | + +#### AdvancedChatWebAppPatch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chat_color_theme | string | Chat colour, e.g. #1C64F2 | No | +| chat_color_theme_inverted | boolean | Invert the chat colours | No | +| copyright | string | Footer copyright text | No | +| custom_disclaimer | string | Disclaimer shown on the page | No | +| default_language | string | Language code, e.g. en-US | No | +| description | string | Page description | No | +| enabled | boolean | Turn the web app on or off | No | +| icon | string | Emoji, file id or URL, per icon_type | No | +| icon_background | string | Background colour for an emoji icon | No | +| icon_type | string,
**Available values:** "emoji", "image", "link" | emoji, image or link | No | +| input_placeholder | string | Placeholder of the chat input | No | +| privacy_policy | string | Privacy policy URL | No | +| show_workflow_steps | boolean | Show each node step to users | No | +| title | string | Page title | No | +| use_icon_as_answer_icon | boolean | Show the app icon on answers | No | + +#### AgentAppInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| max_active_requests | integer | | No | +| name | string | | Yes | +| role | string | | No | +| use_icon_as_answer_icon | boolean | | No | + +#### AgentAppInfoPatch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Pass an empty string to clear | No | +| icon | string | Emoji, file id or URL, per icon_type | No | +| icon_background | string | Background colour for an emoji icon | No | +| icon_type | string,
**Available values:** "emoji", "image", "link" | emoji, image or link | No | +| max_active_requests | integer | Concurrent run cap; 0 uses the server default. The server-wide cap still applies | No | +| name | string | App name | No | +| role | string | The agent's role; empty string clears it | No | +| use_icon_as_answer_icon | boolean | Show the app icon on answers | No | + +#### AgentServiceApi + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_ready | boolean | | Yes | +| api_rph | integer | | No | +| api_rpm | integer | | No | +| base_url | string | | Yes | +| enabled | boolean | | Yes | + +#### AgentWebApp + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_ready | boolean | | Yes | +| access_token | string | | No | +| app_base_url | string | | Yes | +| chat_color_theme | string | | No | +| chat_color_theme_inverted | boolean | | No | +| copyright | string | | No | +| custom_disclaimer | string | | No | +| default_language | string | | No | +| description | string | | No | +| enabled | boolean | | Yes | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| input_placeholder | string | | No | +| privacy_policy | string | | No | +| title | string | | No | +| url | string | | No | +| use_icon_as_answer_icon | boolean | | No | + #### AppDescribeInfo | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | description | string | | No | | id | string | | Yes | -| is_agent | boolean | | No | +| is_agent | boolean | True for agent-chat and advanced-chat apps | No | | mode | string | | Yes | | name | string | | Yes | | service_api_enabled | boolean | | Yes | @@ -664,6 +1996,7 @@ Empty / omitted → all blocks. Unknown member → ValidationError → 422. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | | info | [AppDescribeInfo](#appdescribeinfo) | | No | | input_schema | object | | No | | parameters | object | | No | @@ -674,7 +2007,7 @@ Query parameters for GET /apps//dsl. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| include_secret | boolean | Include encrypted secret values in the exported DSL | No | +| include_secret | boolean | Include secret values in plaintext in the exported DSL | No | | workflow_id | string | Export a specific workflow version instead of the current draft | No | #### AppDslExportResponse @@ -684,6 +2017,7 @@ Export DSL response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | data | string | DSL YAML string | Yes | +| draft_hash | string | Hash of the draft's graph, features, environment variables and conversation variables; pass it to the import to refuse overwriting newer edits | No | #### AppDslImportPayload @@ -693,6 +2027,7 @@ Request body for POST /workspaces//apps/imports. | ---- | ---- | ----------- | -------- | | app_id | string | Existing app ID to overwrite (workflow/advanced-chat apps only) | No | | description | string | Override the app description from the DSL | No | +| draft_hash | string | draft_hash from the export or restore this import is based on. The import fails if the draft's graph, features, environment variables or conversation variables changed since. Requires app_id | No | | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | @@ -727,6 +2062,17 @@ Request body for POST /workspaces//apps/imports. | mode | string | | Yes | | name | string | | Yes | +#### AppInfoPatch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Pass an empty string to clear | No | +| icon | string | Emoji, file id or URL, per icon_type | No | +| icon_background | string | Background colour for an emoji icon | No | +| icon_type | string,
**Available values:** "emoji", "image", "link" | emoji, image or link | No | +| max_active_requests | integer | Concurrent run cap; 0 uses the server default. The server-wide cap still applies | No | +| name | string | App name | No | + #### AppListQuery mode is a closed enum of listable app types. @@ -735,7 +2081,7 @@ mode is a closed enum of listable app types. | ---- | ---- | ----------- | -------- | | limit | integer,
**Default:** 20 | | No | | mode | [SupportedAppType](#supportedapptype) | | No | -| name | string | | No | +| name | string | Filter by a name substring; an app id returns that one app and ignores `mode` | No | | page | integer,
**Default:** 1 | | No | | workspace_id | string (uuid) | | Yes | @@ -768,18 +2114,101 @@ mode is a closed enum of listable app types. | ---- | ---- | ----------- | -------- | | AppMode | string | | | +#### AppSettingsInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| max_active_requests | integer | | No | +| name | string | | Yes | + +#### ChatAppInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| max_active_requests | integer | | No | +| name | string | | Yes | +| use_icon_as_answer_icon | boolean | | No | + +#### ChatAppInfoPatch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Pass an empty string to clear | No | +| icon | string | Emoji, file id or URL, per icon_type | No | +| icon_background | string | Background colour for an emoji icon | No | +| icon_type | string,
**Available values:** "emoji", "image", "link" | emoji, image or link | No | +| max_active_requests | integer | Concurrent run cap; 0 uses the server default. The server-wide cap still applies | No | +| name | string | App name | No | +| use_icon_as_answer_icon | boolean | Show the app icon on answers | No | + #### ChatRunPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No | +| attachments | [ string ] | Local file paths attached to the run itself (the app's `sys.files`), not to a variable | No | | auto_generate_name | boolean,
**Default:** true | Let the server name a new conversation | No | | conversation_id | string | Continue an existing conversation | No | -| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[]`. Send a list (part name `files[][]`) for a file-list variable | No | -| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes | +| files | object | Local file paths keyed by the app's file variable name; each file is uploaded and becomes that variable's value. Give a list of paths for a file-list variable | No | +| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local path in `files`, not both. | No | | query | string | User message | Yes | | workspace_id | string | Workspace that owns the app | No | +#### ChatWebApp + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | No | +| app_base_url | string | | Yes | +| chat_color_theme | string | | No | +| chat_color_theme_inverted | boolean | | No | +| copyright | string | | No | +| custom_disclaimer | string | | No | +| default_language | string | | No | +| description | string | | No | +| enabled | boolean | | Yes | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| input_placeholder | string | | No | +| privacy_policy | string | | No | +| title | string | | No | +| url | string | | No | +| use_icon_as_answer_icon | boolean | | No | + +#### ChatWebAppPatch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chat_color_theme | string | Chat colour, e.g. #1C64F2 | No | +| chat_color_theme_inverted | boolean | Invert the chat colours | No | +| copyright | string | Footer copyright text | No | +| custom_disclaimer | string | Disclaimer shown on the page | No | +| default_language | string | Language code, e.g. en-US | No | +| description | string | Page description | No | +| enabled | boolean | Turn the web app on or off | No | +| icon | string | Emoji, file id or URL, per icon_type | No | +| icon_background | string | Background colour for an emoji icon | No | +| icon_type | string,
**Available values:** "emoji", "image", "link" | emoji, image or link | No | +| input_placeholder | string | Placeholder of the chat input | No | +| privacy_policy | string | Privacy policy URL | No | +| title | string | Page title | No | +| use_icon_as_answer_icon | boolean | Show the app icon on answers | No | + +#### CheckDependenciesResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| leaked_dependencies | [ [PluginDependency](#plugindependency) ] | | No | + #### CheckDependenciesResult | Name | Type | Description | Required | @@ -790,12 +2219,66 @@ mode is a closed enum of listable app types. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No | -| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[]`. Send a list (part name `files[][]`) for a file-list variable | No | -| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes | +| attachments | [ string ] | Local file paths attached to the run itself (the app's `sys.files`), not to a variable | No | +| files | object | Local file paths keyed by the app's file variable name; each file is uploaded and becomes that variable's value. Give a list of paths for a file-list variable | No | +| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local path in `files`, not both. | No | | query | string | Prompt text; most completion apps take their input through `inputs` | No | | workspace_id | string | Workspace that owns the app | No | +#### CreateAppPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | App description | No | +| icon | string | Emoji, file id or URL, per icon_type | No | +| icon_background | string | Background colour for an emoji icon | No | +| icon_type | string,
**Available values:** "emoji", "image", "link" | emoji, image or link | No | +| name | string | App name | Yes | + +#### CreatedAppResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | Yes | +| mode | string | | Yes | +| name | string | | Yes | + +#### CredentialFormField + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| label | string | | Yes | +| name | string | | Yes | +| options | [ string ] | Allowed values, when the field is a choice | Yes | +| placeholder | string | | Yes | +| required | boolean | | Yes | +| show_on | [ object ] | Show this field only when these other fields have these values | Yes | +| type | string | | Yes | + +#### CredentialRef + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| name | string | | Yes | + +#### CredentialWriteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| active | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| id | string | | Yes | +| name | string | | Yes | + +#### CustomModelRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| active_credential | [CredentialRef](#credentialref) | | Yes | +| model | string | | Yes | +| model_type | string | | Yes | + #### DeploymentEdition Enum representing the deployment edition of the platform. @@ -877,6 +2360,30 @@ instead of ``ErrorBody`` because device clients parse it. | token_id | string | | Yes | | workspaces | [ [WorkspacePayload](#workspacepayload) ],
**Default:** | | No | +#### DraftWorkflowRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| attachments | [ string ] | Local file paths attached to the run itself (the app's `sys.files`), not to a variable | No | +| files | object | Local file paths keyed by the app's file variable name; each file is uploaded and becomes that variable's value. Give a list of paths for a file-list variable | No | +| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local path in `files`, not both. | No | +| workspace_id | string | Workspace that owns the app | No | + +#### DslCheckPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | Check against this existing app's mode, as an overwrite import would | No | +| yaml_content | string | The DSL to check, as YAML text | Yes | + +#### DslCheckResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| issues | [ [DslIssueRow](#dslissuerow) ] | | Yes | +| valid | boolean | No error-severity issues | Yes | + #### DslImportWarning Portable DSL reference that could not be restored in the target workspace. @@ -888,6 +2395,53 @@ Portable DSL reference that could not be restored in the target workspace. | message | string | | Yes | | path | string | | Yes | +#### DslIssueRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| loc | [ string
integer ] | | Yes | +| message | string | | Yes | +| node_id | string | | Yes | +| severity | string | error: import refuses it; warning: import allows it, the release check does not | Yes | + +#### EnvVariableListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [EnvVariableRow](#envvariablerow) ] | | Yes | + +#### EnvVariableRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| id | string | What set and delete take to address this variable | Yes | +| name | string | What nodes use to refer to this variable | Yes | +| value | | The value; a secret with a value is masked, an empty one reads as empty | Yes | +| value_type | string | | Yes | + +#### EnvVariableSetPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | What the variable is for | No | +| name | string | Variable name | Yes | +| value | | The value; sending the masked value of an existing secret keeps the stored one | Yes | +| value_type | [EnvVariableValueType](#envvariablevaluetype) | string, number or secret | Yes | + +#### EnvVariableValueType + +Value types the draft environment-variable ``set`` op accepts. + +A curated subset of ``SegmentType``: what the console's environment-variable editor +allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py). +Members reference ``SegmentType.*.value`` so the subset relationship is type-checked. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| EnvVariableValueType | string | Value types the draft environment-variable ``set`` op accepts. A curated subset of ``SegmentType``: what the console's environment-variable editor allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py). Members reference ``SegmentType.*.value`` so the subset relationship is type-checked. | | + #### ErrorBody Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the @@ -899,6 +2453,7 @@ future server adds a code. Formatter tests pin emitted values to the enum. | code | string | | Yes | | details | [ [ErrorDetail](#errordetail) ] | | No | | hint | string | | No | +| hints | [ [Hint](#hint) ] | | No | | message | string | | Yes | | status | integer | | Yes | @@ -944,12 +2499,13 @@ future server adds a code. Formatter tests pin emitted values to the enum. #### FormSubmitResponse -Empty 200 body for POST /apps//human-input-forms/:submit. `extra='forbid'` -pins `additionalProperties: false` so the generated contract is an exact `{}` rather -than an under-annotated open object. +200 body for POST /apps//human-input-forms/:submit: the hint to follow the +resumed run. `extra='forbid'` pins `additionalProperties: false` so the generated contract +is exact rather than an under-annotated open object. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | #### Github @@ -970,11 +2526,11 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required. #### Hint -A next step the caller can hand straight to `call --input `. +A next step: the op id to call and its ready input. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| form | [ object ] | Form fields behind `input.inputs`, copied from the pausing event | No | +| form | [ object ] | Form fields behind the hint's input: a paused run's form inputs, or credentials | No | | input | object | Ready-to-send input for `op`; unknown values are null | Yes | | op | string | | Yes | | summary | string | | Yes | @@ -1015,6 +2571,55 @@ A next step the caller can hand straight to `call --input `. | ---- | ---- | ----------- | -------- | | JsonValue | | | | +#### KnowledgeBaseListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | +| query | string | Words to match in the knowledge base name | No | + +#### KnowledgeBaseListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [KnowledgeBaseRow](#knowledgebaserow) ] | | Yes | +| has_more | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### KnowledgeBaseRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | Yes | +| document_count | integer | | Yes | +| id | string | | Yes | +| indexing_technique | string | | Yes | +| name | string | | Yes | +| node_data | [KnowledgeRetrievalFragment](#knowledgeretrievalfragment) | Merge into a knowledge-retrieval node's data and set query_variable_selector; to search several bases, join their dataset_ids. For rerank or weights read describe node_type | Yes | +| provider | string | vendor: indexed in Dify; external: an external knowledge API | Yes | +| usable | boolean | false when its embedding model isn't available in this workspace | Yes | + +#### KnowledgeRetrievalFragment + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| dataset_ids | [ string ] | | Yes | +| multiple_retrieval_config | [MultipleRetrievalFragment](#multipleretrievalfragment) | | No | +| retrieval_mode | string,
**Default:** multiple | | No | + +#### LlmModelBlock + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| completion_params | object | | No | +| mode | string | | Yes | +| name | string | | Yes | +| provider | string | | Yes | + #### Marketplace | Name | Type | Description | Required | @@ -1022,6 +2627,41 @@ A next step the caller can hand straight to `call --input `. | marketplace_plugin_unique_identifier | string | | Yes | | version | string | | No | +#### MarketplacePluginListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [MarketplacePluginRow](#marketplacepluginrow) ] | | Yes | +| has_more | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### MarketplacePluginQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| category | [PluginCategory](#plugincategory) | Only plugins of this category | No | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | +| query | string | Words to search for; empty lists the most installed plugins | No | + +#### MarketplacePluginRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| authorized_category | string | Who vouches for the plugin: langgenius (official), partner or community; workspace install-scope rules check this | Yes | +| brief | string | | Yes | +| category | string | | Yes | +| identifier | string | Latest versioned id; pass it to install.plugin | Yes | +| install_count | integer | | Yes | +| installed | boolean | | Yes | +| installed_version | string | | Yes | +| label | string | | Yes | +| plugin_id | string | | Yes | +| version | string | | Yes | + #### MemberActionResponse | Name | Type | Description | Required | @@ -1090,6 +2730,143 @@ Strict (extra='forbid'). | retriever_resources | [ object ],
**Default:** | | No | | usage | [UsageInfo](#usageinfo) | | No | +#### ModelCredentialCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | Secret values, keyed by the field names in credential_form of the describe op. | Yes | +| model | string | Model name, as in get.model | Yes | +| model_type | [ModelType](#modeltype) | | Yes | +| name | string | | No | + +#### ModelCredentialUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | Secret values, keyed by the field names in credential_form of the describe op. Send [__HIDDEN__] for a secret you keep unchanged. | Yes | +| model | string | Model name, as in get.model | Yes | +| model_type | [ModelType](#modeltype) | | Yes | +| name | string | | No | + +#### ModelListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer,
**Default:** 20 | | No | +| model_type | [ModelType](#modeltype) | Only models of this type | No | +| page | integer,
**Default:** 1 | | No | +| provider | string | Only this provider's models, such as langgenius/openai/openai | No | +| query | string | Words to match in the model name, label or provider | No | + +#### ModelListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ModelRow](#modelrow) ] | | Yes | +| has_more | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### ModelProviderDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| active_credential | [CredentialRef](#credentialref) | | Yes | +| configured | boolean | | Yes | +| credential_form | [ [CredentialFormField](#credentialformfield) ] | | Yes | +| credentials | [ [CredentialRef](#credentialref) ] | | Yes | +| custom_model_form | [ [CredentialFormField](#credentialformfield) ] | | Yes | +| custom_models | [ [CustomModelRow](#custommodelrow) ] | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| label | string | | Yes | +| model_types | [ string ] | | Yes | +| provider | string | Provider id such as langgenius/openai/openai | Yes | + +#### ModelProviderRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| active_credential | [CredentialRef](#credentialref) | | Yes | +| configured | boolean | | Yes | +| label | string | | Yes | +| model_types | [ string ] | | Yes | +| provider | string | Provider id such as langgenius/openai/openai | Yes | + +#### ModelRef + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model | string | Model name, as in get.model | Yes | +| model_type | [ModelType](#modeltype) | | Yes | + +#### ModelRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| features | [ string ] | | Yes | +| label | string | | Yes | +| model | string | | Yes | +| model_type | string | | Yes | +| node_model | [LlmModelBlock](#llmmodelblock) | LLM rows: paste as the node's model | No | +| provider | string | | Yes | +| provider_label | string | | Yes | +| status | string | active means usable now; anything else needs the provider set up | Yes | + +#### ModelType + +Enum class for model type. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ModelType | string | Enum class for model type. | | + +#### MultipleRetrievalFragment + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| reranking_enable | boolean | | No | +| top_k | integer,
**Default:** 4 | | No | + +#### NodeChange + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| fields | [ string ] | | Yes | +| id | string | | Yes | +| title | string | | Yes | +| type | string | | Yes | + +#### NodeRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | object | Local file paths keyed by the file variable name (a start variable or #node.var#); each is uploaded | No | +| inputs | object | Overrides for what the last draft run saved, keyed by variable reference such as #llm.text# | No | + +#### NodeTypeDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default_config | object | | Yes | +| schema | object | | Yes | +| type | string | | Yes | +| version | string | | Yes | + +#### NodeTypeListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [NodeTypeRow](#nodetyperow) ] | | Yes | + +#### NodeTypeRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | string | | Yes | +| version | string | | Yes | + #### OpenApiErrorCode | Name | Type | Description | Required | @@ -1103,7 +2880,7 @@ The console payload plus local file parts; `_files.merge_files` sets them on `in | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | -| files | object | Local files keyed by the form's file input name, same convention as the run ops' `files` | No | +| files | object | Local file paths keyed by the form's file input name, same convention as the run ops' `files` | No | | inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | #### Package @@ -1135,6 +2912,19 @@ Strict (extra='forbid'). | page | integer | | Yes | | total | integer | | Yes | +#### PluginCategory + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| PluginCategory | string | | | + +#### PluginDeleteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| deleted | boolean | | Yes | +| plugin_id | string | | Yes | + #### PluginDependency | Name | Type | Description | Required | @@ -1149,12 +2939,171 @@ Strict (extra='forbid'). | ---- | ---- | ----------- | -------- | | PluginDependencyType | string | | | +#### PluginInstallPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| identifiers | [ string ] | Versioned plugin ids such as langgenius/openai:0.2.1@sha256…, from get.marketplace.plugin (identifier) or check.console_app.dependency | Yes | + +#### PluginInstallTaskStatus + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| PluginInstallTaskStatus | string | | | + +#### PluginListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| category | [PluginCategory](#plugincategory) | Only plugins of this category | No | + +#### PluginListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [PluginRow](#pluginrow) ] | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | + +#### PluginProvides + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model_provider | string | Model provider id this plugin adds, for describe.model_provider | Yes | +| tool_provider | string | Tool provider id this plugin adds, for describe.tool_provider | Yes | + +#### PluginRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| category | string | | Yes | +| identifier | string | | Yes | +| label | string | | Yes | +| latest_version | string | | Yes | +| plugin_id | string | | Yes | +| provides | [PluginProvides](#pluginprovides) | | Yes | +| source | string | | Yes | +| version | string | | Yes | + +#### PluginTaskItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| identifier | string | | Yes | +| message | string | | Yes | +| plugin_id | string | | Yes | +| status | [PluginInstallTaskStatus](#plugininstalltaskstatus) | | Yes | + +#### PluginTaskResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| plugins | [ [PluginTaskItem](#plugintaskitem) ] | | Yes | +| status | [PluginInstallTaskStatus](#plugininstalltaskstatus) | | Yes | +| task_id | string | | Yes | + +#### PluginTaskStartResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| all_installed | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| task_id | string | | Yes | + +#### ProviderCredentialCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | Secret values, keyed by the field names in credential_form of the describe op. | Yes | +| name | string | Credential name; the server makes one when absent | No | + +#### ProviderCredentialUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | Secret values, keyed by the field names in credential_form of the describe op. Send [__HIDDEN__] for a secret you keep unchanged. | Yes | +| name | string | | No | + +#### PublishPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| marked_comment | string | Version note | No | +| marked_name | string | Version name | No | + +#### PublishResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | Yes | +| version_id | string | | Yes | +| warning | string | Variable references that may read a skipped branch | No | + +#### ReleaseCheckName + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ReleaseCheckName | string | | | + +#### ReleaseCheckResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| changes | [WorkflowDiff](#workflowdiff) | | Yes | +| checks | [ [ReleaseCheckRow](#releasecheckrow) ] | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| issues | [ [DslIssueRow](#dslissuerow) ] | | Yes | +| ready | boolean | Every check passed; show the human `changes` and ask before publishing | Yes | +| state | [ReleaseState](#releasestate) | | Yes | + +#### ReleaseCheckRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| detail | string | | Yes | +| name | [ReleaseCheckName](#releasecheckname) | | Yes | +| passed | boolean | | Yes | + +#### ReleaseState + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| service_api_enabled | boolean | | Yes | +| webapp_enabled | boolean | | Yes | + +#### RestoreResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| draft_hash | string | Hash of the restored draft's graph, features, environment variables and conversation variables; pass it to a DSL import as draft_hash | Yes | +| result | string | | Yes | + #### RevokeResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | status | string | | Yes | +#### RunListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | Cursor: id of the last run on the previous page | No | +| limit | integer,
**Default:** 20 | | No | +| status | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | | No | +| triggered_from | string,
**Available values:** "app-run", "debugging",
**Default:** debugging | debugging: draft test runs, as in the console; app-run: real use
*Enum:* `"app-run"`, `"debugging"` | No | + +#### RunListResponse + +Cursor page of runs; `hints` carries the next page. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowRunForListResponse](#workflowrunforlistresponse) ] | | Yes | +| has_more | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| limit | integer | | Yes | + #### ServerVersionResponse Meta endpoint payload for `GET /openapi/v1/_version` — no auth required. @@ -1164,6 +3113,19 @@ Meta endpoint payload for `GET /openapi/v1/_version` — no auth required. | edition | [DeploymentEdition](#deploymentedition) | | Yes | | version | string | | Yes | +#### ServiceApi + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| base_url | string | | Yes | +| enabled | boolean | | Yes | + +#### ServiceApiPatch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | Turn the app's Service API on or off | Yes | + #### SessionListQuery Pagination for GET /account/sessions. Strict (extra='forbid'). @@ -1196,6 +3158,23 @@ Pagination for GET /account/sessions. Strict (extra='forbid'). | last_used_at | string | | No | | prefix | string | | Yes | +#### SimpleAccountResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | + +#### SimpleEndUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| is_anonymous | boolean | | Yes | +| session_id | string | | No | +| type | string | | Yes | + #### SimpleResultResponse | Name | Type | Description | Required | @@ -1236,6 +3215,134 @@ types it as a required `'success'` rather than an optional field. | ---- | ---- | ----------- | -------- | | result | string | | Yes | +#### ToolCredentialCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | Secret values, keyed by the field names in credential_form of the describe op. | Yes | +| name | string | | No | + +#### ToolCredentialUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | Secret values, keyed by the field names in credential_form of the describe op. Send [__HIDDEN__] for a secret you keep unchanged. | Yes | +| name | string | | No | + +#### ToolInputType + +Binding modes for persisted tool input values. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolInputType | string | Binding modes for persisted tool input values. | | + +#### ToolInputValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | [ToolInputType](#toolinputtype) | | No | +| value | | | No | + +#### ToolListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | +| provider | string | Only this provider's tools | No | +| provider_type | [ToolSource](#toolsource) | Only tools of this provider type | No | +| query | string | Words to match in the tool name or label, or the provider id or label | No | + +#### ToolListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ToolRow](#toolrow) ] | | Yes | +| has_more | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### ToolNodeTemplate + +A tool node's data with every value as {type, value}. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugin_id | string | | Yes | +| plugin_unique_identifier | string | | Yes | +| provider_id | string | | Yes | +| provider_name | string | | Yes | +| provider_type | string | | Yes | +| title | string | | Yes | +| tool_configurations | object | | Yes | +| tool_label | string | | Yes | +| tool_name | string | | Yes | +| tool_node_version | string,
**Default:** 2 | | No | +| tool_parameters | object | | Yes | +| type | string,
**Default:** tool | | No | + +#### ToolParameterRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default | | | Yes | +| form | string | llm: goes in tool_parameters; form: goes in tool_configurations; schema: fixed | Yes | +| label | string | | Yes | +| llm_description | string | | Yes | +| max | number | | Yes | +| min | number | | Yes | +| name | string | | Yes | +| options | [ string ] | | Yes | +| required | boolean | | Yes | +| type | string | | Yes | + +#### ToolProviderDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| configured | boolean | Ready to use: needs no credential, or the workspace has one | Yes | +| credential_form | [ [CredentialFormField](#credentialformfield) ] | Fields of an api-key credential | Yes | +| credential_types | [ string ] | api-key can be set here; oauth2 needs the console | Yes | +| credentials | [ [CredentialRef](#credentialref) ] | | Yes | +| default_credential | [CredentialRef](#credentialref) | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| label | string | | Yes | +| provider | string | Tool provider id such as langgenius/tavily/tavily | Yes | + +#### ToolProviderRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| configured | boolean | Ready to use: needs no credential, or the workspace has one | Yes | +| credential_types | [ string ] | api-key can be set here; oauth2 needs the console | Yes | +| label | string | | Yes | +| provider | string | Tool provider id such as langgenius/tavily/tavily | Yes | + +#### ToolRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| configured | boolean | Ready to use: needs no credential, or the workspace has one. MCP: authorize or refresh in the console | Yes | +| description | string | | Yes | +| label | string | | Yes | +| name | string | | Yes | +| node_data | [ToolNodeTemplate](#toolnodetemplate) | Use as the tool node's data; fill every null value; pass upstream values as {type: mixed, value: "{{#node.var#}}"} or {type: variable, value: [node, var]} | Yes | +| parameters | [ [ToolParameterRow](#toolparameterrow) ] | | Yes | +| provider | string | | Yes | +| provider_label | string | | Yes | +| provider_type | string | | Yes | + +#### ToolSource + +Tool provider types a workflow tool node can call; values match the node's provider_type. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolSource | string | Tool provider types a workflow tool node can call; values match the node's provider_type. | | + #### UsageInfo | Name | Type | Description | Required | @@ -1244,6 +3351,110 @@ types it as a required `'success'` rather than an optional field. | prompt_tokens | integer | | No | | total_tokens | integer | | No | +#### VersionListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer,
**Default:** 20 | | No | +| named_only | boolean | Only versions that have a name | No | +| page | integer,
**Default:** 1 | | No | + +#### VersionListResponse + +Page of published versions, newest first; there is no total, `hints` carries the next page. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [VersionRow](#versionrow) ] | | Yes | +| has_more | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| limit | integer | | Yes | +| page | integer | | Yes | + +#### VersionRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | Yes | +| created_by | string | | No | +| current | boolean | | No | +| id | string | | Yes | +| marked_comment | string | | Yes | +| marked_name | string | | Yes | + +#### WebApp + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | No | +| app_base_url | string | | Yes | +| copyright | string | | No | +| custom_disclaimer | string | | No | +| default_language | string | | No | +| description | string | | No | +| enabled | boolean | | Yes | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| privacy_policy | string | | No | +| title | string | | No | +| url | string | | No | + +#### WebAppAccess + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_mode | string | | Yes | + +#### WebAppAccessMode + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| WebAppAccessMode | string | | | + +#### WebAppAccessPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_mode | [WebAppAccessMode](#webappaccessmode) | public, private_all or sso_verified. Choose specific members (private) in the Dify console | Yes | + +#### WebAppPatch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| copyright | string | Footer copyright text | No | +| custom_disclaimer | string | Disclaimer shown on the page | No | +| default_language | string | Language code, e.g. en-US | No | +| description | string | Page description | No | +| enabled | boolean | Turn the web app on or off | No | +| icon | string | Emoji, file id or URL, per icon_type | No | +| icon_background | string | Background colour for an emoji icon | No | +| icon_type | string,
**Available values:** "emoji", "image", "link" | emoji, image or link | No | +| privacy_policy | string | Privacy policy URL | No | +| title | string | Page title | No | + +#### WebAppToken + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | No | +| app_base_url | string | | Yes | +| url | string | | No | + +#### WorkflowDiff + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| edges_added | [ string ] | | Yes | +| edges_removed | [ string ] | | Yes | +| env_added | [ string ] | | Yes | +| env_removed | [ string ] | | Yes | +| features_changed | boolean | | Yes | +| nodes_added | [ [NodeChange](#nodechange) ] | | Yes | +| nodes_changed | [ [NodeChange](#nodechange) ] | | Yes | +| nodes_removed | [ [NodeChange](#nodechange) ] | | Yes | +| published | boolean | false when the app was never published; then everything counts as added | Yes | + #### WorkflowRunData | Name | Type | Description | Required | @@ -1259,22 +3470,127 @@ types it as a required `'success'` rather than an optional field. | total_tokens | integer | | No | | workflow_id | string | | Yes | +#### WorkflowRunDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No | +| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | +| created_by_role | string | | No | +| elapsed_time | number | | No | +| error | string | | No | +| exceptions_count | integer | | No | +| finished_at | integer | | No | +| graph | | | Yes | +| id | string | | Yes | +| inputs | | | Yes | +| outputs | | | Yes | +| status | string | | No | +| total_steps | integer | | No | +| total_tokens | integer | | No | +| version | string | | No | + +#### WorkflowRunForListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No | +| elapsed_time | number | | No | +| exceptions_count | integer | | No | +| finished_at | integer | | No | +| id | string | | Yes | +| retry_index | integer | | No | +| status | string | | No | +| total_steps | integer | | No | +| total_tokens | integer | | No | +| version | string | | No | + +#### WorkflowRunNodeExecutionListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) ] | | Yes | + +#### WorkflowRunNodeExecutionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No | +| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | +| created_by_role | string | | No | +| elapsed_time | number | | No | +| error | string | | No | +| execution_metadata | | | No | +| extras | | | No | +| finished_at | integer | | No | +| id | string | | Yes | +| index | integer | | No | +| inputs | | | No | +| inputs_truncated | boolean | | No | +| node_id | string | | No | +| node_type | string | | No | +| outputs | | | No | +| outputs_truncated | boolean | | No | +| predecessor_node_id | string | | No | +| process_data | | | No | +| process_data_truncated | boolean | | No | +| retry_index | integer | | No | +| status | string | | No | +| title | string | | No | + #### WorkflowRunPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No | -| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[]`. Send a list (part name `files[][]`) for a file-list variable | No | -| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes | +| attachments | [ string ] | Local file paths attached to the run itself (the app's `sys.files`), not to a variable | No | +| files | object | Local file paths keyed by the app's file variable name; each file is uploaded and becomes that variable's value. Give a list of paths for a file-list variable | No | +| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local path in `files`, not both. | No | | workflow_id | string | Pin a published workflow version | No | | workspace_id | string | Workspace that owns the app | No | +#### WorkflowWebApp + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | No | +| app_base_url | string | | Yes | +| copyright | string | | No | +| custom_disclaimer | string | | No | +| default_language | string | | No | +| description | string | | No | +| enabled | boolean | | Yes | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| privacy_policy | string | | No | +| show_workflow_steps | boolean | | No | +| title | string | | No | +| url | string | | No | + +#### WorkflowWebAppPatch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| copyright | string | Footer copyright text | No | +| custom_disclaimer | string | Disclaimer shown on the page | No | +| default_language | string | Language code, e.g. en-US | No | +| description | string | Page description | No | +| enabled | boolean | Turn the web app on or off | No | +| icon | string | Emoji, file id or URL, per icon_type | No | +| icon_background | string | Background colour for an emoji icon | No | +| icon_type | string,
**Available values:** "emoji", "image", "link" | emoji, image or link | No | +| privacy_policy | string | Privacy policy URL | No | +| show_workflow_steps | boolean | Show each node step to users | No | +| title | string | Page title | No | + #### WorkspaceDetailResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | created_at | string | | No | -| current | boolean | | Yes | | id | string | | Yes | | name | string | | Yes | | role | string | | Yes | @@ -1312,7 +3628,6 @@ Strict (extra='forbid'). | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| current | boolean | | Yes | | id | string | | Yes | | name | string | | Yes | | role | string | | Yes | diff --git a/api/repositories/app/console_repository.py b/api/repositories/app/console_repository.py index f9753a01486..aede4f8a5be 100644 --- a/api/repositories/app/console_repository.py +++ b/api/repositories/app/console_repository.py @@ -57,6 +57,7 @@ from services.entities.app_entities import ( AppCreationSettings, AppDeletion, AppEvent, + AppFieldChanges, AppListBaseParams, AppListParams, AppListSortBy, @@ -77,6 +78,7 @@ from services.errors.base import NoPermissionError from services.openapi.visibility import apply_openapi_gate logger = logging.getLogger(__name__) + _app_trace_settings_adapter = TypeAdapter(AppTraceSettings) @@ -244,6 +246,13 @@ class ConsoleAppRepository(ConsoleApps, DiscoveryApps, RelatedApps): app = self.update_app(app, changes, account_id=context.account_id, session=session) return app_record(app, session=session, projection="detail-with-site") + @override + def update_fields(self, context: RequestContext, app_id: str, changes: AppFieldChanges) -> AppRecord: + with self._session_factory() as session: + app = require_console_app(session, context, app_id) + app = self.update_app_fields(app, changes, account_id=context.account_id, session=session) + return app_record(app, session=session, projection="detail-with-site") + @override def rename(self, context: RequestContext, app_id: str, name: str) -> AppRecord: with self._session_factory() as session: @@ -283,6 +292,17 @@ class ConsoleAppRepository(ConsoleApps, DiscoveryApps, RelatedApps): app = self.update_app_api_status(app, enabled, account_id=context.account_id, session=session) return AppChange(app_record(app, session=session, projection="detail"), changed) + @override + def access_ready(self, context: RequestContext, app_id: str) -> bool: + with self._session_factory() as session: + return self.is_agent_app_access_ready(require_console_app(session, context, app_id), session=session) + + @override + def agent_role(self, context: RequestContext, app_id: str) -> str | None: + with self._session_factory() as session: + agent = self._get_backing_agent(require_console_app(session, context, app_id), session=session) + return agent.role if agent is not None else None + @override def set_starred(self, context: RequestContext, app_id: str, starred: bool) -> None: with self._session_factory.begin() as session: @@ -801,6 +821,33 @@ class ConsoleAppRepository(ConsoleApps, DiscoveryApps, RelatedApps): return app + @classmethod + def update_app_fields(cls, app: App, changes: AppFieldChanges, *, account_id: str, session: Session) -> App: + """Write only the fields in `changes`; the others keep their stored values.""" + app.name = changes.get("name", app.name) + app.description = changes.get("description", app.description) + app.icon_type = changes.get("icon_type", app.icon_type) + app.icon = changes.get("icon", app.icon) + app.icon_background = changes.get("icon_background", app.icon_background) + app.use_icon_as_answer_icon = changes.get("use_icon_as_answer_icon", app.use_icon_as_answer_icon) + app.max_active_requests = changes.get("max_active_requests", app.max_active_requests) + app.updated_by = account_id + app.updated_at = naive_utc_now() + cls._sync_backing_agent_identity( + app, + name=changes.get("name"), + description=changes.get("description"), + role=changes.get("role"), + icon_type=changes.get("icon_type"), + icon=changes.get("icon"), + icon_background=changes.get("icon_background"), + account_id=account_id, + updated_at=app.updated_at, + session=session, + ) + cls._commit_app_identity_update(app, session=session) + return app + @classmethod def update_app_name(cls, app: App, name: str, *, account_id: str, session: Session) -> App: """ diff --git a/api/repositories/app/dsl_import_repository.py b/api/repositories/app/dsl_import_repository.py new file mode 100644 index 00000000000..646dbaff170 --- /dev/null +++ b/api/repositories/app/dsl_import_repository.py @@ -0,0 +1,37 @@ +"""Own the read Session for the openapi DSL import rules.""" + +from collections.abc import Generator, Mapping +from contextlib import contextmanager +from typing import override + +from sqlalchemy.orm import Session, sessionmaker + +from graphon.variables import VariableBase +from machinery.context import RequestContext +from repositories.app.console_repository import require_console_app +from services.workflow.dsl_import import DslImportStore, DslImportTarget, stored_secret_ids +from services.workflow.graph_check import ResourceCheck, credential_check +from services.workflow.graph_diff import draft_token +from services.workflow_service import WorkflowService + + +class DslImportRepository(DslImportStore): + def __init__(self, *, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + @override + def load_target(self, context: RequestContext, app_id: str) -> DslImportTarget: + with self._session_factory() as session: + app = require_console_app(session, context, app_id) + draft = WorkflowService.get_draft_workflow(app, session=session) + return DslImportTarget( + mode=app.mode, + draft_token=draft_token(draft) if draft else None, + stored_secret_ids=frozenset(stored_secret_ids(draft)) if draft else frozenset(), + ) + + @override + @contextmanager + def credential_check(self, workspace_id: str, environment: Mapping[str, VariableBase]) -> Generator[ResourceCheck]: + with self._session_factory() as session: + yield credential_check(workspace_id, environment, session) diff --git a/api/repositories/sqlalchemy_execution_extra_content_repository.py b/api/repositories/sqlalchemy_execution_extra_content_repository.py index 5ba8793417b..84d9d6aa9b4 100644 --- a/api/repositories/sqlalchemy_execution_extra_content_repository.py +++ b/api/repositories/sqlalchemy_execution_extra_content_repository.py @@ -124,7 +124,9 @@ class SQLAlchemyExecutionExtraContentRepository(ExecutionExtraContentRepository) display_in_ui = bool(form_definition.display_in_ui) submitted = form.submitted_at is not None or form.status == HumanInputFormStatus.SUBMITTED - form_token = self._resolve_form_token(recipients_by_form_id.get(form.id, [])) if not submitted else None + # Only a waiting form can still be answered; a timed-out or expired one gets no token. + answerable = not submitted and form.status == HumanInputFormStatus.WAITING + form_token = self._resolve_form_token(recipients_by_form_id.get(form.id, [])) if answerable else None form_definition_domain_model = HumanInputFormDefinition( form_id=form.id, node_id=form.node_id, diff --git a/api/services/app/console_gateway.py b/api/services/app/console_gateway.py index 72b26aea5b7..47935a1c882 100644 --- a/api/services/app/console_gateway.py +++ b/api/services/app/console_gateway.py @@ -2,10 +2,11 @@ from __future__ import annotations +import copy from collections.abc import Callable from contextlib import AbstractContextManager from dataclasses import dataclass, replace -from typing import BinaryIO, Literal, override +from typing import Any, BinaryIO, Final, Literal, override from uuid import UUID from sqlalchemy.exc import IntegrityError @@ -15,6 +16,9 @@ from configs import dify_config from core.rbac import RBACPermission from enums import DeploymentEdition from events.app_event import app_was_updated +from graphon.enums import BuiltinNodeTypes +from graphon.model_runtime.entities.llm_entities import LLMMode +from graphon.model_runtime.entities.message_entities import PromptMessageRole from machinery.context import RequestContext from models.account import Account from models.agent import AgentIconType @@ -58,6 +62,7 @@ from services.entities.dsl_entities import ( AppImportPackage, AppImportParams, CheckDependenciesResult, + DraftGuardedImportParams, Import, ImportStatus, ) @@ -67,8 +72,74 @@ from services.rbac import contracts as rbac_contracts from services.rbac.members import MemberService from services.recommended_app_package_service import RecommendedAppPackageService from services.system_feature_service import SystemFeatureService +from services.workflow_service import WorkflowService from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task +# Positions as the console editor lays out its templates (START_INITIAL_POSITION and +# NODE_WIDTH_X_OFFSET in web/app/components/workflow/constants.ts). +_START_POSITION: Final = (80, 282) +_NODE_X_STEP: Final = 300 + + +def _editor_node(node_id: str, column: int, data: dict[str, Any]) -> dict[str, Any]: + """A node as the editor's generateNewNode shapes it.""" + x, y = _START_POSITION + return { + "id": node_id, + "type": "custom", + "data": data, + "position": {"x": x + _NODE_X_STEP * column, "y": y}, + "targetPosition": "left", + "sourcePosition": "right", + } + + +def _editor_edge(source: str, target: str) -> dict[str, Any]: + return { + "id": f"{source}-{target}", + "source": source, + "sourceHandle": "source", + "target": target, + "targetHandle": "target", + } + + +# The Chatflow template the console editor saves on first open (use-workflow-template.ts). +_CHATFLOW_STARTER_GRAPH: Final[dict[str, Any]] = { + "nodes": [ + _editor_node("start", 0, {"type": BuiltinNodeTypes.START, "title": "Start", "variables": []}), + _editor_node( + "llm", + 1, + { + "type": BuiltinNodeTypes.LLM, + "title": "LLM", + "model": {"provider": "", "name": "", "mode": LLMMode.CHAT, "completion_params": {"temperature": 0.7}}, + "prompt_template": [{"role": PromptMessageRole.SYSTEM, "text": ""}], + "context": {"enabled": False, "variable_selector": []}, + "vision": {"enabled": False}, + "memory": { + "window": {"enabled": False, "size": 10}, + "query_prompt_template": "{{#sys.query#}}\n\n{{#sys.files#}}", + }, + }, + ), + _editor_node( + "answer", + 2, + {"type": BuiltinNodeTypes.ANSWER, "title": "Answer", "variables": [], "answer": "{{#llm.text#}}"}, + ), + ], + "edges": [_editor_edge("start", "llm"), _editor_edge("llm", "answer")], +} + +# The draft a new app starts with. A workflow starts empty: its entry (Start or a trigger) is chosen while building. +_STARTER_DRAFT_GRAPHS: Final[dict[AppMode, dict[str, Any]]] = { + AppMode.WORKFLOW: {"nodes": [], "edges": []}, + AppMode.ADVANCED_CHAT: _CHATFLOW_STARTER_GRAPH, +} +_STARTER_DRAFT_FEATURES: Final[dict[str, Any]] = {"retriever_resource": {"enabled": True}} + @dataclass(frozen=True) class ConsoleAppPermissions(AppPermissions): @@ -246,6 +317,7 @@ class AppTransferGateway(AppTransfers, AppDefinitionImports): app_id=params.app_id, package=package, preserve_agent_bindings=as_copy, + draft_hash=params.draft_hash if isinstance(params, DraftGuardedImportParams) else None, ) if result.status == ImportStatus.FAILED or (as_copy and result.status == ImportStatus.PENDING): session.rollback() @@ -389,6 +461,21 @@ class AppLifecycleGateway(AppLifecycle): raise AgentNameConflictError() from exc return app_record(app, session=session, projection="detail-with-site") + @override + def create_draft(self, context: RequestContext, app_id: str) -> None: + with self._session_factory() as session: + app = require_console_app(session, context, app_id) + WorkflowService(self._session_factory).sync_draft_workflow( + app_model=app, + graph=copy.deepcopy(_STARTER_DRAFT_GRAPHS[AppMode(app.mode)]), + features=copy.deepcopy(_STARTER_DRAFT_FEATURES), + unique_hash=None, + account=console_app_actor(session, context), + environment_variables=[], + conversation_variables=[], + session=session, + ) + @override def delete(self, context: RequestContext, app_id: str) -> AppDeletion: with self._session_factory.begin() as session: diff --git a/api/services/app/console_service.py b/api/services/app/console_service.py index 795132285f9..9ec2e62574e 100644 --- a/api/services/app/console_service.py +++ b/api/services/app/console_service.py @@ -16,6 +16,7 @@ from services.entities.app_entities import ( AppCreationSettings, AppDeletion, AppExportOptions, + AppFieldChanges, AppListParams, AppPage, AppRecord, @@ -98,6 +99,8 @@ class ConsoleApps(Protocol): def update(self, context: RequestContext, app_id: str, params: UpdateAppParams) -> AppRecord: ... + def update_fields(self, context: RequestContext, app_id: str, changes: AppFieldChanges) -> AppRecord: ... + def rename(self, context: RequestContext, app_id: str, name: str) -> AppRecord: ... def update_icon( @@ -108,6 +111,10 @@ class ConsoleApps(Protocol): def set_api_enabled(self, context: RequestContext, app_id: str, enabled: bool) -> AppChange: ... + def access_ready(self, context: RequestContext, app_id: str) -> bool: ... + + def agent_role(self, context: RequestContext, app_id: str) -> str | None: ... + def set_starred(self, context: RequestContext, app_id: str, starred: bool) -> None: ... def get_reference(self, context: RequestContext, app_id: str) -> AppReference: ... @@ -173,6 +180,8 @@ class AppTraceProvider(Protocol): class AppLifecycle(Protocol): def create(self, context: RequestContext, params: CreateAppParams, settings: AppCreationSettings) -> AppRecord: ... + def create_draft(self, context: RequestContext, app_id: str) -> None: ... + def delete(self, context: RequestContext, app_id: str) -> AppDeletion: ... def prepare_creation(self, context: RequestContext, params: CreateAppParams) -> AppCreationSettings: ... @@ -204,6 +213,14 @@ class ConsoleAppService: self._tracing = tracing self._lifecycle = lifecycle + def access_ready(self, context: RequestContext, app_id: str) -> bool: + """Whether an agent app has a published version its web app and Service API can serve.""" + return self._apps.access_ready(context, app_id) + + def agent_role(self, context: RequestContext, app_id: str) -> str | None: + """The role of an agent app's backing agent; None for other apps.""" + return self._apps.agent_role(context, app_id) + def import_app(self, context: RequestContext, params: AppImportParams, *, source: BinaryIO | None = None) -> Import: if source is not None: return self._import_package(context, params, source) @@ -332,11 +349,27 @@ class ConsoleAppService: self._access.initialize_created_app(context, app.id) return self._lifecycle.present(context, replace(app, permission_keys=keys)) + def create_with_draft(self, context: RequestContext, params: CreateAppParams) -> AppRecord: + """Create a workflow-type app with the starter draft the editor saves on first open, so it exports as DSL. + An app whose draft could not be saved is deleted again.""" + app = self.create(context, params) + try: + self._lifecycle.create_draft(context, app.id) + except Exception: + self.delete(context, app.id) + raise + return app + def update(self, context: RequestContext, app_id: str, params: UpdateAppParams) -> AppRecord: app = self._apps.update(context, app_id, params) self._lifecycle.updated(context, app) return self.get(context, app_id) + def update_fields(self, context: RequestContext, app_id: str, changes: AppFieldChanges) -> AppRecord: + app = self._apps.update_fields(context, app_id, changes) + self._lifecycle.updated(context, app) + return self.get(context, app_id) + def rename(self, context: RequestContext, app_id: str, name: str) -> AppRecord: app = self._apps.rename(context, app_id, name) self._lifecycle.updated(context, app) diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 2ef385d77f8..b8be7825b79 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -75,7 +75,7 @@ from services.entities.dsl_entities import ( make_app_dsl, ) from services.entities.site_dsl import SiteDsl, apply_site_dsl -from services.errors.app import WorkflowNotFoundError +from services.errors.app import DraftChangedError, WorkflowNotFoundError from services.errors.base import NoPermissionError from services.feature_service import FeatureService from services.icon_configuration import ( @@ -84,6 +84,7 @@ from services.icon_configuration import ( is_valid_image_icon, ) from services.plugin.dependencies_analysis import DependenciesAnalysisService +from services.workflow.graph_diff import draft_token from services.workflow_draft_variable_service import WorkflowDraftVariableService from services.workflow_service import WorkflowService @@ -140,6 +141,7 @@ class PendingData(PendingImportOwner): icon: str | None = None icon_background: str | None = None app_id: str | None = None + draft_hash: str | None = None warnings: list[DslImportWarning] = Field(default_factory=list) @@ -171,8 +173,13 @@ class AppDslService: import_app_id: str | None = None, package: AppImportPackage | None = None, preserve_agent_bindings: bool = False, + draft_hash: str | None = None, ) -> Import: - """Import an App DSL after checking Site entitlements and staging archive resources.""" + """Import an App DSL after checking Site entitlements and staging archive resources. + + With ``draft_hash``, an overwrite saves only while the draft's ``draft_token`` still equals it and + raises DraftChangedError otherwise; a pending import keeps it for the confirm. + """ self._warnings = [] import_id = str(uuid.uuid4()) @@ -336,6 +343,7 @@ class AppDslService: icon=icon, icon_background=icon_background, app_id=app_id, + draft_hash=draft_hash, warnings=self._warnings, ) redis_client.setex( @@ -382,6 +390,7 @@ class AppDslService: import_app_id=import_app_id, allow_premium_site_settings=allow_premium_site_settings, preserve_agent_bindings=preserve_agent_bindings, + draft_hash=draft_hash, ) draft_var_srv = WorkflowDraftVariableService(session=self._session) @@ -404,7 +413,7 @@ class AppDslService: error=f"Invalid YAML format: {str(e)}", ) - except NoPermissionError: + except (NoPermissionError, DraftChangedError): raise except Exception as e: @@ -482,6 +491,7 @@ class AppDslService: icon=pending_data.icon, icon_background=pending_data.icon_background, allow_premium_site_settings=allow_premium_site_settings, + draft_hash=pending_data.draft_hash, ) # Delete import info from Redis @@ -498,7 +508,7 @@ class AppDslService: warnings=self._warnings, ) - except NoPermissionError: + except (NoPermissionError, DraftChangedError): raise except Exception as e: @@ -610,6 +620,18 @@ class AppDslService: if any(node.get("data", {}).get("type") in invalid_types for node in nodes): raise ValueError("Workflow contains node types incompatible with the target App") + def _lock_draft_matching(self, *, app: App | None, draft_hash: str) -> Workflow: + """Lock the draft the caller exported, or raise DraftChangedError when it has moved on. + + The row lock holds until commit, so the draft stays the one the caller exported. + """ + if app is None: + raise DraftChangedError() + draft = WorkflowService().get_draft_workflow_for_update(app_model=app, session=self._session) + if draft is None or draft_token(draft) != draft_hash: + raise DraftChangedError() + return draft + def _create_or_update_app( self, *, @@ -625,6 +647,7 @@ class AppDslService: import_app_id: str | None = None, allow_premium_site_settings: bool = True, preserve_agent_bindings: bool = False, + draft_hash: str | None = None, ) -> App: """Create a new app or update an existing one.""" app_data = data.get("app", {}) @@ -632,6 +655,8 @@ class AppDslService: if not app_mode: raise ValueError("loss app mode") app_mode = AppMode(app_mode) + # Check the draft before any write so a refused import leaves the app and its cached dependencies alone. + locked_draft = self._lock_draft_matching(app=app, draft_hash=draft_hash) if draft_hash is not None else None site_data = SiteDsl.model_validate(data["site"]) if data.get("site") is not None else None if app_mode == AppMode.AGENT: self._ensure_agent_import_permission(account, app=app) @@ -722,7 +747,9 @@ class AppDslService: ] workflow_service = WorkflowService() - current_draft_workflow = workflow_service.get_draft_workflow(app_model=app, session=self._session) + current_draft_workflow = locked_draft or workflow_service.get_draft_workflow( + app_model=app, session=self._session + ) unique_hash = current_draft_workflow.unique_hash if current_draft_workflow else None graph = workflow_data.get("graph", {}) if not isinstance(graph, dict): diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index b64bfc914e2..c3217dcf93b 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -312,7 +312,12 @@ class EnterpriseService: def update_app_access_mode(cls, app_id: str, access_mode: str): if not app_id: raise ValueError("app_id must be provided.") - allowed = {WebAppAccessMode.PUBLIC, WebAppAccessMode.PRIVATE, WebAppAccessMode.PRIVATE_ALL} + allowed = { + WebAppAccessMode.PUBLIC, + WebAppAccessMode.PRIVATE, + WebAppAccessMode.PRIVATE_ALL, + WebAppAccessMode.SSO_VERIFIED, + } if access_mode not in allowed: raise ValueError(f"access_mode must be one of: {', '.join(m.value for m in allowed)}") diff --git a/api/services/entities/app_entities.py b/api/services/entities/app_entities.py index f67fa47a1e5..a14e3dc97d6 100644 --- a/api/services/entities/app_entities.py +++ b/api/services/entities/app_entities.py @@ -271,6 +271,19 @@ class AppUpdateArguments(TypedDict): role: NotRequired[str | None] +class AppFieldChanges(TypedDict, total=False): + """A partial update of the App row and its backing agent's role: only the keys present are written.""" + + name: str + description: str + icon_type: IconType + icon: str + icon_background: str + use_icon_as_answer_icon: bool + max_active_requests: int + role: str + + @dataclass(frozen=True, slots=True) class ImportedAppPackage: app_id: str diff --git a/api/services/entities/dsl_entities.py b/api/services/entities/dsl_entities.py index bd8a8959121..fb97e7ee89a 100644 --- a/api/services/entities/dsl_entities.py +++ b/api/services/entities/dsl_entities.py @@ -37,6 +37,12 @@ class AppImportParams(BaseModel): app_id: str | None = None +class DraftGuardedImportParams(AppImportParams): + """An overwrite that saves only while the draft's ``draft_token`` still equals ``draft_hash``.""" + + draft_hash: str | None = None + + @dataclass(frozen=True, slots=True) class AppDslExportData: """Materialized export input; remote dependencies can be resolved after closing the read transaction.""" diff --git a/api/services/errors/app.py b/api/services/errors/app.py index d84b36b2ae5..cde92c2eb95 100644 --- a/api/services/errors/app.py +++ b/api/services/errors/app.py @@ -14,6 +14,10 @@ class WorkflowHashNotEqualError(Exception): pass +class DraftChangedError(Exception): + """The draft no longer matches the export an overwrite is based on.""" + + class IsDraftWorkflowError(Exception): pass diff --git a/api/services/model_provider/service.py b/api/services/model_provider/service.py index a1e218f8cca..5d0ca59e980 100644 --- a/api/services/model_provider/service.py +++ b/api/services/model_provider/service.py @@ -199,6 +199,11 @@ class ModelProviderService: return provider_responses + def get_provider(self, tenant_id: str, provider: str) -> ProviderResponse: + """One provider as `get_provider_list` lists it; raises ProviderNotFoundError.""" + name = self._get_provider_configuration(tenant_id, provider).provider.provider + return next(response for response in self.get_provider_list(tenant_id) if response.provider == name) + @staticmethod def _load_provider_summary_states(tenant_id: str) -> dict[str, _ProviderSummaryState]: """Load only the workspace columns required by the collapsed provider list.""" @@ -442,6 +447,13 @@ class ModelProviderService: } return provider_summaries, plugin_summaries + def list_models( + self, tenant_id: str, *, provider: str | None = None, model_type: ModelType | None = None + ) -> list[ModelWithProviderEntity]: + """Models of every provider in the workspace, optionally narrowed to one provider or model type.""" + provider_configurations = self._get_provider_manager(tenant_id).get_configurations(tenant_id) + return provider_configurations.get_models(provider=provider, model_type=model_type) + def get_models_by_provider(self, tenant_id: str, provider: str) -> list[ModelWithProviderEntityResponse]: """ get provider models. @@ -512,7 +524,7 @@ class ModelProviderService: def create_provider_credential( self, tenant_id: str, provider: str, credentials: dict[str, Any], credential_name: str | None - ) -> None: + ) -> str: """ Create and save new provider credentials. @@ -523,7 +535,7 @@ class ModelProviderService: :return: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) - provider_configuration.create_provider_credential(credentials, credential_name) + return provider_configuration.create_provider_credential(credentials, credential_name) def update_provider_credential( self, @@ -615,7 +627,7 @@ class ModelProviderService: model: str, credentials: dict[str, Any], credential_name: str | None, - ) -> None: + ) -> str: """ create and save model credentials. @@ -628,7 +640,7 @@ class ModelProviderService: :return: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) - provider_configuration.create_custom_model_credential( + return provider_configuration.create_custom_model_credential( model_type=ModelType(model_type), model=model, credentials=credentials, diff --git a/api/services/oauth_device_adapters.py b/api/services/oauth_device_adapters.py index 354f8a9208e..6cf8981aab8 100644 --- a/api/services/oauth_device_adapters.py +++ b/api/services/oauth_device_adapters.py @@ -1,7 +1,6 @@ """Outer adapters for the OAuth device external-SSO use cases.""" import logging -import os import secrets from collections.abc import Mapping from datetime import UTC, datetime, timedelta @@ -46,10 +45,6 @@ from services.oauth_device_contracts import ( logger = logging.getLogger(__name__) _EMAIL_FIELD = Field(min_length=3, max_length=320, pattern=r"^[^@\s]+@[^@\s]+$") -_DEFAULT_OAUTH_TTL_DAYS = 14 -_MIN_OAUTH_TTL_DAYS = 1 -_MAX_OAUTH_TTL_DAYS = 365 -_TTL_ENV_VAR = "OAUTH_TTL_DAYS" _OAUTH_TOKEN_BODY_BYTES = 32 _RESERVE_APPROVAL_NONCE_LUA = """ local current = redis.call('GET', KEYS[1]) @@ -92,28 +87,9 @@ class EnvironmentOAuthDeviceTokenTTLPolicy(OAuthDeviceTokenTTLPolicy): @override def ttl_days(self, workspace_id: str | None) -> int: # Reserved for a future tenant-specific policy, which should take - # precedence over the deployment-wide environment value. + # precedence over the deployment-wide setting. _ = workspace_id - raw = os.environ.get(_TTL_ENV_VAR) - if raw is None: - return _DEFAULT_OAUTH_TTL_DAYS - try: - value = int(raw) - except ValueError: - logger.warning( - "%s=%r is not an int; falling back to %d", - _TTL_ENV_VAR, - raw, - _DEFAULT_OAUTH_TTL_DAYS, - ) - return _DEFAULT_OAUTH_TTL_DAYS - if value < _MIN_OAUTH_TTL_DAYS: - logger.warning("%s=%d below min %d; clamping", _TTL_ENV_VAR, value, _MIN_OAUTH_TTL_DAYS) - return _MIN_OAUTH_TTL_DAYS - if value > _MAX_OAUTH_TTL_DAYS: - logger.warning("%s=%d above max %d; clamping", _TTL_ENV_VAR, value, _MAX_OAUTH_TTL_DAYS) - return _MAX_OAUTH_TTL_DAYS - return value + return dify_config.OAUTH_TTL_DAYS class OAuthDeviceTokenIssuanceGateway(OAuthDeviceTokenIssuer): diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index dd7fd0f2152..6eea7bffc77 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -303,10 +303,11 @@ class BuiltinToolManageService: session.add(db_provider) session.flush() + credential_id = db_provider.id except Exception as e: raise ValueError(str(e)) - return {"result": "success"} + return {"result": "success", "id": credential_id} @staticmethod def create_tool_encrypter( diff --git a/api/services/webapp_access_adapters.py b/api/services/webapp_access_adapters.py index d75478fa981..c4debe18196 100644 --- a/api/services/webapp_access_adapters.py +++ b/api/services/webapp_access_adapters.py @@ -15,6 +15,8 @@ from services.webapp_access_query_service import WebAppAccessPolicyGateway, WebA class EnterpriseWebAppAuthService(Protocol): def get_app_access_mode_by_id(self, app_id: str) -> WebAppSettings: ... + def update_app_access_mode(self, app_id: str, access_mode: str) -> object: ... + def is_user_allowed_to_access_webapp(self, user_id: str, app_id: str) -> bool: ... @@ -40,6 +42,18 @@ class EnterpriseWebAppAccessPolicyGateway(WebAppAccessPolicyGateway): except ValueError as error: raise WebAppAccessUnavailableError from error + def update_access_mode(self, app_id: str, access_mode: WebAppAccessMode) -> None: + try: + self._webapp_auth.update_app_access_mode(app_id, access_mode) + except ( + EnterpriseServiceError, + httpx.RequestError, + json.JSONDecodeError, + UnicodeDecodeError, + ValidationError, + ) as error: + raise WebAppAccessUnavailableError from error + @override def is_user_allowed(self, *, user_id: str, app_id: str) -> bool: try: diff --git a/api/services/workflow/dsl_import.py b/api/services/workflow/dsl_import.py new file mode 100644 index 00000000000..1e22833ecd1 --- /dev/null +++ b/api/services/workflow/dsl_import.py @@ -0,0 +1,201 @@ +"""The openapi DSL import rules; the console import uses none of them. + +Before an import: refuse a stale overwrite (draft_hash), refuse a graph with errors, fill the +defaults the editor needs, and keep the secrets the draft already stores (an export blanks them). +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from contextlib import AbstractContextManager +from dataclasses import dataclass +from typing import Any, Protocol +from urllib.parse import urlparse + +import yaml + +from constants import HIDDEN_VALUE +from core.file import remote_fetcher +from factories import variable_factory +from graphon.variables import SecretVariable, SegmentType, VariableBase +from graphon.variables.exc import VariableError +from machinery.context import RequestContext +from models import AppMode, Workflow +from services.dsl_content import DSL_MAX_SIZE, dsl_content_size +from services.entities.dsl_entities import ImportMode +from services.errors.app import DraftChangedError +from services.workflow.graph_check import ( + GRAPH_MODES, + GraphIssue, + IssueSeverity, + ResourceCheck, + check_graph, +) +from services.workflow.node_defaults import fill_graph + + +class DslNotCheckableError(ValueError): + pass + + +class YamlUrlFetchError(ValueError): + pass + + +class DslRefusedError(Exception): + def __init__(self, issues: Sequence[GraphIssue]) -> None: + super().__init__("The DSL has errors the import refuses") + self.issues = list(issues) + + +def stored_secret_ids(draft: Workflow) -> set[str]: + return {variable.id for variable in draft.environment_variables if isinstance(variable, SecretVariable)} + + +@dataclass(frozen=True) +class DslImportTarget: + """What the import rules read from the app a DSL overwrites.""" + + mode: str + draft_token: str | None + stored_secret_ids: frozenset[str] + + +class DslImportStore(Protocol): + def load_target(self, context: RequestContext, app_id: str) -> DslImportTarget: + """Raises ConsoleAppNotFoundError for an app outside the workspace.""" + ... + + def credential_check( + self, workspace_id: str, environment: Mapping[str, VariableBase] + ) -> AbstractContextManager[ResourceCheck]: + """Publish's credential check, usable until the context exits.""" + ... + + +def fetch_yaml_url(yaml_url: str) -> str: + """The YAML text at a URL, fetched as AppDslService.import_app does; a GitHub blob link reads its raw file.""" + try: + parsed_url = urlparse(yaml_url) + if ( + parsed_url.scheme == "https" + and parsed_url.netloc == "github.com" + and parsed_url.path.endswith((".yml", ".yaml")) + and "/blob/" in parsed_url.path + ): + yaml_url = yaml_url.replace("https://github.com", "https://raw.githubusercontent.com") + yaml_url = yaml_url.replace("/blob/", "/") + response = remote_fetcher.make_request("GET", yaml_url.strip(), follow_redirects=True, timeout=(10, 10)) + response.raise_for_status() + raw_content = response.content + if dsl_content_size(raw_content) > DSL_MAX_SIZE: + raise YamlUrlFetchError("File size exceeds the limit of 10MB") + content = raw_content.decode("utf-8") + except YamlUrlFetchError: + raise + except Exception as e: + raise YamlUrlFetchError(f"Error fetching YAML from URL: {str(e)}") from e + if not content: + raise YamlUrlFetchError("Empty content from url") + return content + + +def _parse(yaml_content: str) -> dict[str, Any]: + # Measured before parsing: re-dumping drops comments, so a padded DSL could shrink under the import's limit. + if dsl_content_size(yaml_content) > DSL_MAX_SIZE: + raise DslNotCheckableError("File size exceeds the limit of 10MB") + try: + data = yaml.safe_load(yaml_content) + except yaml.YAMLError as error: + raise DslNotCheckableError(f"Invalid YAML: {error}") from error + if not isinstance(data, dict): + raise DslNotCheckableError("The DSL must be a YAML mapping") + return data + + +def _section(data: Mapping[str, Any], key: str) -> dict[str, Any]: + value = data.get(key) + return value if isinstance(value, dict) else {} + + +def _graph(workflow: Mapping[str, Any]) -> Mapping[str, Any]: + graph = workflow.get("graph") + return graph if isinstance(graph, Mapping) else {} + + +def _graph_mode(data: Mapping[str, Any], target: DslImportTarget | None) -> AppMode: + """The mode the graph must fit: the overwritten app's, else the DSL's own.""" + mode = target.mode if target is not None else _section(data, "app").get("mode") + if mode not in GRAPH_MODES: + raise DslNotCheckableError("Only workflow or advanced-chat DSLs can be checked") + return AppMode(mode) + + +def _environment(workflow: Mapping[str, Any]) -> dict[str, VariableBase]: + items = workflow.get("environment_variables") or [] + if not isinstance(items, list) or not all(isinstance(item, Mapping) for item in items): + raise DslNotCheckableError("workflow.environment_variables must be a list of mappings") + try: + variables = [variable_factory.build_environment_variable_from_mapping(item) for item in items] + except VariableError as error: + raise DslNotCheckableError(str(error)) from error + return {variable.name: variable for variable in variables} + + +def _keep_stored_secrets(workflow: Mapping[str, Any], stored: frozenset[str]) -> None: + for variable in workflow.get("environment_variables") or []: + if ( + isinstance(variable, dict) + and variable.get("value_type") == SegmentType.SECRET + and variable.get("value") == "" + and variable.get("id") in stored + ): + variable["value"] = HIDDEN_VALUE + + +class DslImportService: + def __init__(self, *, store: DslImportStore) -> None: + self._store = store + + def _target(self, context: RequestContext, app_id: str | None) -> DslImportTarget | None: + return self._store.load_target(context, app_id) if app_id else None + + def check(self, context: RequestContext, yaml_content: str, app_id: str | None) -> list[GraphIssue]: + """Every issue, errors and credential warnings alike. + + Raises DslNotCheckableError for a DSL that is not a workflow graph or has a bad environment + variable, ConsoleAppNotFoundError for an `app_id` outside the workspace. + """ + data = _parse(yaml_content) + workflow = _section(data, "workflow") + mode = _graph_mode(data, self._target(context, app_id)) + environment = _environment(workflow) + with self._store.credential_check(context.active_workspace_id, environment) as resources: + return check_graph(_graph(workflow), mode=mode, resources=resources) + + def prepare( + self, context: RequestContext, *, mode: ImportMode, source: str, app_id: str | None, draft_hash: str | None + ) -> str: + """The YAML to hand the import. A DSL the check can't read goes through unchanged, for the import to judge. + + A URL is fetched only after the target app and draft pass. Raises DraftChangedError, DslRefusedError, + YamlUrlFetchError, or ConsoleAppNotFoundError for an `app_id` outside the workspace. + """ + target = self._target(context, app_id) + if draft_hash is not None and (target is None or target.draft_token != draft_hash): + raise DraftChangedError() + yaml_content = fetch_yaml_url(source) if mode == ImportMode.YAML_URL else source + try: + data = _parse(yaml_content) + app_mode = _graph_mode(data, target) + except DslNotCheckableError: + return yaml_content + workflow = _section(data, "workflow") + errors = [i for i in check_graph(_graph(workflow), mode=app_mode) if i.code.severity is IssueSeverity.ERROR] + if errors: + raise DslRefusedError(errors) + if "graph" in workflow: + workflow["graph"] = fill_graph(_graph(workflow)) + if target is not None: + _keep_stored_secrets(workflow, target.stored_secret_ids) + return yaml.safe_dump(data, allow_unicode=True, sort_keys=False) diff --git a/api/services/workflow/graph_check.py b/api/services/workflow/graph_check.py new file mode 100644 index 00000000000..18d6e1569ad --- /dev/null +++ b/api/services/workflow/graph_check.py @@ -0,0 +1,329 @@ +"""Problems in a workflow graph that would fail at import, in the editor or at run time, each located.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, Final + +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from core.trigger.constants import TRIGGER_NODE_TYPES +from core.workflow.human_input_adapter import adapt_node_config_for_graph +from core.workflow.node_factory import resolve_workflow_node_class +from core.workflow.variable_prefixes import ROOT_VARIABLE_NODE_IDS +from graphon.entities.graph_config import NodeConfigDictAdapter +from graphon.enums import BuiltinNodeTypes +from graphon.nodes.base.node import Node +from graphon.variables import VariableBase +from models import AppMode +from services.workflow.node_defaults import complete_sub_models, editor_defaults, node_data_type +from services.workflow_service import WorkflowService +from services.workflow_variable_reference_validator import SOURCE_HANDLE, branch_handles + +logger = logging.getLogger(__name__) + +GRAPH_MODES: Final = frozenset({AppMode.WORKFLOW, AppMode.ADVANCED_CHAT}) +CONTAINER_NODE_TYPES: Final = frozenset({BuiltinNodeTypes.ITERATION, BuiltinNodeTypes.LOOP}) +_CANVAS_NOTE_TYPE: Final = "custom-note" + + +class IssueSeverity(StrEnum): + ERROR = "error" + WARNING = "warning" + + +class IssueCode(StrEnum): + GRAPH_INVALID = "graph_invalid" + MODE_INCOMPATIBLE = "mode_incompatible" + UNKNOWN_NODE_TYPE = "unknown_node_type" + NODE_DATA_INVALID = "node_data_invalid" + EDGE_ENDPOINT_MISSING = "edge_endpoint_missing" + BRANCH_HANDLE_INVALID = "branch_handle_invalid" + REFERENCE_MISSING = "reference_missing" + CONTAINER_START_MISSING = "container_start_missing" + OUTPUT_NAME_DUPLICATE = "output_name_duplicate" + BRANCH_UNCONNECTED = "branch_unconnected" + RESOURCE_UNAVAILABLE = "resource_unavailable" + + @property + def severity(self) -> IssueSeverity: + return IssueSeverity.WARNING if self in _WARNING_CODES else IssueSeverity.ERROR + + +_WARNING_CODES: Final = frozenset({IssueCode.BRANCH_UNCONNECTED, IssueCode.RESOURCE_UNAVAILABLE}) + + +@dataclass(frozen=True) +class GraphIssue: + code: IssueCode + message: str + node_id: str | None = None + loc: tuple[str | int, ...] = field(default_factory=tuple) + + +ResourceCheck = Callable[[Mapping[str, Any]], str | None] + + +def refused_node_types(mode: AppMode) -> frozenset[str]: + if mode == AppMode.ADVANCED_CHAT: + return frozenset({BuiltinNodeTypes.END, *TRIGGER_NODE_TYPES}) + return frozenset({BuiltinNodeTypes.ANSWER}) + + +def credential_check(workspace_id: str, environment: Mapping[str, VariableBase], session: Session) -> ResourceCheck: + """A `ResourceCheck` reporting what publish's credential check would refuse for one node.""" + + service = WorkflowService() + + def check(node: Mapping[str, Any]) -> str | None: + try: + service.validate_node_credentials(workspace_id, node, environment, session=session) + except ValueError as error: + return str(error) + return None + + return check + + +@dataclass(frozen=True) +class _Node: + """One node read once: its data with editor defaults filled, what reading it found wrong, + and the variables it references (empty when its data could not be read).""" + + id: str + data: Mapping[str, Any] + issues: tuple[GraphIssue, ...] = () + references: Mapping[str, Sequence[str]] = field(default_factory=dict) + + @property + def type(self) -> str: + return str(self.data.get("type", "")) + + +@dataclass(frozen=True) +class _Graph: + nodes: Mapping[str, _Node] + edges: Sequence[Mapping[str, Any]] + mode: AppMode + + +def _data_loc(node_id: str, *path: str | int) -> tuple[str | int, ...]: + return ("nodes", node_id, "data", *path) + + +def _references(node_class: type[Node], config: Mapping[str, Any]) -> Mapping[str, Sequence[str]]: + """The node's own references; a container's children are read on their own, so it gets a graph of itself.""" + return node_class.extract_variable_selector_to_variable_mapping( + graph_config={"nodes": [config], "edges": []}, config=NodeConfigDictAdapter.validate_python(config) + ) + + +def _read_known_node(node_id: str, raw_data: Mapping[str, Any], node_class: type[Node]) -> _Node: + data = editor_defaults(raw_data) + model = node_data_type(node_class.node_type, node_class) + config = adapt_node_config_for_graph({"id": node_id, "data": data}) + try: + model.model_validate(config["data"]) + except ValidationError as error: + problems = error.errors(include_url=False, include_input=False, include_context=False) + issues = tuple( + GraphIssue(IssueCode.NODE_DATA_INVALID, p["msg"], node_id, _data_loc(node_id, *p["loc"])) for p in problems + ) + return _Node(node_id, data, issues) + return _Node(node_id, complete_sub_models(model, data), references=_references(node_class, config)) + + +def _read_node(node_id: str, raw: Mapping[str, Any]) -> _Node: + raw_data: Mapping[str, Any] = raw["data"] + node_type = str(raw_data.get("type", "")) + try: + node_class = resolve_workflow_node_class( + node_type=node_type, node_version=str(raw_data.get("version", "1")), node_data=raw_data + ) + except ValueError: + issue = GraphIssue( + IssueCode.UNKNOWN_NODE_TYPE, f"Unknown node type {node_type!r}", node_id, _data_loc(node_id, "type") + ) + return _Node(node_id, raw_data, (issue,)) + try: + return _read_known_node(node_id, raw_data, node_class) + except Exception as error: + # A validator or reference reader can raise other errors on bad or newer data; report this node, check the rest. + logger.warning("node %s data could not be read", node_id, exc_info=True) + message = f"The node data could not be read ({type(error).__name__}: {error})" + return _Node( + node_id, raw_data, (GraphIssue(IssueCode.NODE_DATA_INVALID, message, node_id, _data_loc(node_id)),) + ) + + +def _mode_issues(graph: _Graph) -> Iterable[GraphIssue]: + refused = refused_node_types(graph.mode) + for node in graph.nodes.values(): + if node.type in refused: + yield GraphIssue( + IssueCode.MODE_INCOMPATIBLE, + f"A {graph.mode} app can't hold a {node.type} node", + node.id, + _data_loc(node.id, "type"), + ) + + +def _node_issues(graph: _Graph) -> Iterable[GraphIssue]: + for node in graph.nodes.values(): + yield from node.issues + start = node.data.get("start_node_id") + if node.type in CONTAINER_NODE_TYPES and start and start not in graph.nodes: + yield GraphIssue( + IssueCode.CONTAINER_START_MISSING, + f"start_node_id {start!r} is not in the graph", + node.id, + _data_loc(node.id, "start_node_id"), + ) + + +def _edge_issues(graph: _Graph) -> Iterable[GraphIssue]: + for index, edge in enumerate(graph.edges): + edge_id = str(edge.get("id", index)) + for end in ("source", "target"): + if edge.get(end) not in graph.nodes: + yield GraphIssue( + IssueCode.EDGE_ENDPOINT_MISSING, + f"Edge {end} {edge.get(end)!r} is not a node", + None, + ("edges", edge_id, end), + ) + source = graph.nodes.get(str(edge.get("source"))) + valid = branch_handles(source.data) if source else None + handle = str(edge.get("sourceHandle", SOURCE_HANDLE)) + if valid is not None and handle not in valid: + yield GraphIssue( + IssueCode.BRANCH_HANDLE_INVALID, + f"sourceHandle {handle!r} is not a branch of {edge.get('source')!r}; use one of {sorted(valid)}", + str(edge.get("source")), + ("edges", edge_id, "sourceHandle"), + ) + + +def _unconnected_branch_issues(graph: _Graph) -> Iterable[GraphIssue]: + for node in graph.nodes.values(): + handles = branch_handles(node.data) + if handles is None: + continue + used = {str(e.get("sourceHandle", SOURCE_HANDLE)) for e in graph.edges if e.get("source") == node.id} + for handle in sorted(handles - used): + yield GraphIssue( + IssueCode.BRANCH_UNCONNECTED, + f"Branch {handle!r} of {node.id!r} has no edge; a run that takes it stops there", + node.id, + ("nodes", node.id), + ) + + +def _output_name_issues(graph: _Graph) -> Iterable[GraphIssue]: + """End nodes share one set of workflow outputs, so the console refuses to publish a name used twice.""" + places: dict[str, list[tuple[str, int]]] = {} + for node in graph.nodes.values(): + outputs = node.data.get("outputs") + if node.type != BuiltinNodeTypes.END or not isinstance(outputs, list): + continue + for index, output in enumerate(outputs): + name = str(output.get("variable") or "").strip() if isinstance(output, Mapping) else "" + if name: + places.setdefault(name, []).append((node.id, index)) + for name, found in places.items(): + if len(found) < 2: + continue + for node_id, index in found: + yield GraphIssue( + IssueCode.OUTPUT_NAME_DUPLICATE, + f"Output {name!r} is used by more than one End node output; output names must be unique", + node_id, + _data_loc(node_id, "outputs", index, "variable"), + ) + + +def _reference_issues(graph: _Graph) -> Iterable[GraphIssue]: + for node in graph.nodes.values(): + for key, selector in node.references.items(): + if selector and selector[0] not in ROOT_VARIABLE_NODE_IDS and selector[0] not in graph.nodes: + yield GraphIssue( + IssueCode.REFERENCE_MISSING, + f"{key} reads node {selector[0]!r}, which is not in the graph", + node.id, + _data_loc(node.id), + ) + + +_CHECKS: Final[tuple[Callable[[_Graph], Iterable[GraphIssue]], ...]] = ( + _mode_issues, + _node_issues, + _edge_issues, + _unconnected_branch_issues, + _output_name_issues, + _reference_issues, +) + + +def _shape_issues(graph: Mapping[str, Any]) -> list[GraphIssue]: + """The shape every later check reads: a list of nodes, each a mapping with a string id and mapping data, + and a list of edge mappings.""" + nodes = graph.get("nodes") + if not isinstance(nodes, list): + return [GraphIssue(IssueCode.GRAPH_INVALID, "graph.nodes must be a list", None, ("nodes",))] + issues: list[GraphIssue] = [] + seen_ids: set[str] = set() + for index, node in enumerate(nodes): + node_id = node.get("id") if isinstance(node, Mapping) else None + if not isinstance(node, Mapping): + issues.append(GraphIssue(IssueCode.GRAPH_INVALID, "A node must be a mapping", None, ("nodes", index))) + elif not isinstance(node_id, str) or not node_id: + issues.append(GraphIssue(IssueCode.GRAPH_INVALID, "A node needs a string id", None, ("nodes", index, "id"))) + elif node_id in seen_ids: + issues.append( + GraphIssue( + IssueCode.GRAPH_INVALID, + f"Node id {node_id!r} is used by more than one node; node ids must be unique", + node_id, + ("nodes", index, "id"), + ) + ) + elif not isinstance(node.get("data"), Mapping): + issues.append( + GraphIssue(IssueCode.GRAPH_INVALID, "The node data must be a mapping", node_id, _data_loc(node_id)) + ) + if isinstance(node_id, str) and node_id: + seen_ids.add(node_id) + edges = graph.get("edges") + if edges is None: + return issues + if not isinstance(edges, list): + return [*issues, GraphIssue(IssueCode.GRAPH_INVALID, "graph.edges must be a list", None, ("edges",))] + issues.extend( + GraphIssue(IssueCode.GRAPH_INVALID, "An edge must be a mapping", None, ("edges", index)) + for index, edge in enumerate(edges) + if not isinstance(edge, Mapping) + ) + return issues + + +def check_graph(graph: Mapping[str, Any], *, mode: AppMode, resources: ResourceCheck | None = None) -> list[GraphIssue]: + shape_issues = _shape_issues(graph) + if shape_issues: + return shape_issues + raw_nodes = {str(n["id"]): n for n in graph["nodes"] if n.get("type") != _CANVAS_NOTE_TYPE} + view = _Graph( + nodes={node_id: _read_node(node_id, raw) for node_id, raw in raw_nodes.items()}, + edges=graph.get("edges") or [], + mode=mode, + ) + issues = [issue for check in _CHECKS for issue in check(view)] + if resources is not None: + for node_id, raw in raw_nodes.items(): + problem = resources(raw) + if problem: + issues.append(GraphIssue(IssueCode.RESOURCE_UNAVAILABLE, problem, node_id, ("nodes", node_id))) + return issues diff --git a/api/services/workflow/graph_diff.py b/api/services/workflow/graph_diff.py new file mode 100644 index 00000000000..c26e7e61d54 --- /dev/null +++ b/api/services/workflow/graph_diff.py @@ -0,0 +1,131 @@ +"""What changed between two versions of a workflow, ignoring editor layout and import defaults.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Final + +from pydantic import BaseModel, Field + +from core.db.session_factory import session_factory +from graphon.variables import SecretVariable, VariableBase +from libs import helper +from models.model import App +from models.workflow import Workflow +from services.workflow.node_defaults import fill_node_data + +_DATA_UI_KEYS: Final = frozenset({"selected", "_connectedSourceHandleIds", "_connectedTargetHandleIds"}) + + +@dataclass(frozen=True) +class WorkflowSnapshot: + graph: Mapping[str, Any] + features: Mapping[str, Any] + environment_variable_names: frozenset[str] + + +class NodeChange(BaseModel): + id: str + type: str + title: str + fields: list[str] + + +class WorkflowDiff(BaseModel): + published: bool = Field(description="false when the app was never published; then everything counts as added") + nodes_added: list[NodeChange] + nodes_removed: list[NodeChange] + nodes_changed: list[NodeChange] + edges_added: list[str] + edges_removed: list[str] + features_changed: bool + env_added: list[str] + env_removed: list[str] + + +def _nodes(graph: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]: + return {str(n.get("id")): n for n in graph.get("nodes") or [] if isinstance(n, Mapping)} + + +def _data(node: Mapping[str, Any]) -> dict[str, Any]: + """Node data with import defaults filled, so a default added only by an import is not a change.""" + data = node.get("data") + if not isinstance(data, Mapping): + return {} + return {k: v for k, v in fill_node_data(data).items() if k not in _DATA_UI_KEYS} + + +def _change(node: Mapping[str, Any], fields: list[str] | None = None) -> NodeChange: + data = _data(node) + return NodeChange( + id=str(node.get("id")), type=str(data.get("type", "")), title=str(data.get("title", "")), fields=fields or [] + ) + + +def _edges(graph: Mapping[str, Any]) -> set[str]: + return { + f"{e.get('source')} → {e.get('target')} ({e.get('sourceHandle', 'source')})" + for e in graph.get("edges") or [] + if isinstance(e, Mapping) + } + + +def _runnable(graph: Mapping[str, Any]) -> dict[str, Any]: + """The graph as a run sees it: the editor's layout and selection state don't count.""" + return {"nodes": {i: _data(n) for i, n in _nodes(graph).items()}, "edges": sorted(_edges(graph))} + + +def same_graph(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool: + return _runnable(a) == _runnable(b) + + +def _variable(variable: VariableBase) -> dict[str, Any]: + return variable.model_dump(mode="json", exclude={"value"} if isinstance(variable, SecretVariable) else None) + + +def draft_token(draft: Workflow) -> str: + """Changes when anything a DSL import replaces changes; editor layout and secret values don't count.""" + content = { + "graph": _runnable(draft.graph_dict), + "features": draft.features_dict, + "environment_variables": [_variable(v) for v in draft.environment_variables], + "conversation_variables": [_variable(v) for v in draft.conversation_variables], + } + return helper.generate_text_hash(json.dumps(content, sort_keys=True)) + + +def current_draft_token(tenant_id: str, app_id: str) -> str | None: + """The app draft's `draft_token`, read in its own session; None when the app has no draft.""" + from services.workflow_service import WorkflowService + + with session_factory.create_session() as session: + app = session.get(App, app_id) + if app is None or app.tenant_id != tenant_id: + return None + draft = WorkflowService.get_draft_workflow(app, session=session) + return draft_token(draft) if draft else None + + +def diff_workflows(published: WorkflowSnapshot | None, draft: WorkflowSnapshot) -> WorkflowDiff: + base = published or WorkflowSnapshot(graph={}, features={}, environment_variable_names=frozenset()) + old, new = _nodes(base.graph), _nodes(draft.graph) + changed = [] + for node_id in sorted(old.keys() & new.keys()): + before, after = _data(old[node_id]), _data(new[node_id]) + fields = sorted(k for k in before.keys() | after.keys() if before.get(k) != after.get(k)) + if fields: + changed.append(_change(new[node_id], fields)) + old_edges, new_edges = _edges(base.graph), _edges(draft.graph) + return WorkflowDiff( + published=published is not None, + nodes_added=[_change(new[i]) for i in sorted(new.keys() - old.keys())], + nodes_removed=[_change(old[i]) for i in sorted(old.keys() - new.keys())], + nodes_changed=changed, + edges_added=sorted(new_edges - old_edges), + edges_removed=sorted(old_edges - new_edges), + features_changed=dict(base.features) != dict(draft.features) if published else False, + env_added=sorted(draft.environment_variable_names - base.environment_variable_names), + env_removed=sorted(base.environment_variable_names - draft.environment_variable_names), + ) diff --git a/api/services/workflow/node_defaults.py b/api/services/workflow/node_defaults.py new file mode 100644 index 00000000000..bf50571f41f --- /dev/null +++ b/api/services/workflow/node_defaults.py @@ -0,0 +1,155 @@ +"""Defaults every node needs so the workflow editor can open it; shared by DSL import and describe.node_type.""" + +from __future__ import annotations + +import copy +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass +from types import UnionType +from typing import Any, Final, Union, get_args, get_origin + +from pydantic import BaseModel, Field + +from core.workflow.human_input_adapter import DeliveryChannelConfig +from core.workflow.node_factory import resolve_workflow_node_class +from core.workflow.nodes.human_input.entities import HumanInputNodeData +from graphon.enums import BuiltinNodeTypes +from graphon.nodes.base.node import Node + +# Only empty containers: values the editor reads without a guard and that mean "none yet". +EDITOR_EMPTY: Final[Mapping[str, Mapping[str, object]]] = { + BuiltinNodeTypes.START: {"variables": []}, + BuiltinNodeTypes.END: {"outputs": []}, + BuiltinNodeTypes.IF_ELSE: {"cases": []}, + BuiltinNodeTypes.CODE: {"variables": []}, + BuiltinNodeTypes.TEMPLATE_TRANSFORM: {"variables": []}, + BuiltinNodeTypes.HTTP_REQUEST: {"body": {"type": "none", "data": []}}, + BuiltinNodeTypes.TOOL: {"tool_parameters": {}, "tool_configurations": {}}, + BuiltinNodeTypes.LOOP: {"break_conditions": []}, + BuiltinNodeTypes.HUMAN_INPUT: {"delivery_methods": [], "user_actions": [], "inputs": []}, + BuiltinNodeTypes.DATASOURCE: {"datasource_parameters": {}}, +} + + +class HumanInputNodeSpec(HumanInputNodeData): + """Human-input data as the editor saves it; the run parses delivery methods beside the node data.""" + + delivery_methods: list[DeliveryChannelConfig] = Field(default_factory=list) + + +# Graphon keeps these node data opaque; Dify parses them with its own model when the node runs. +_DIFY_NODE_DATA: Final[Mapping[str, type[BaseModel]]] = { + BuiltinNodeTypes.HUMAN_INPUT: HumanInputNodeSpec, +} + + +def node_data_type(node_type: str, node_class: type[Node]) -> type[BaseModel]: + return _DIFY_NODE_DATA.get(node_type) or node_class._get_node_data_type() + + +_LEGACY_IF_CASE_ID: Final = "true" +_DEFAULT_LOGICAL_OPERATOR: Final = "and" + + +def _cases_from_legacy_conditions(data: Mapping[str, Any]) -> Mapping[str, Any]: + """Old if-else nodes keep one condition list and branch on "true"; the editor reads them as one case.""" + if "cases" in data or "conditions" not in data: + return data + case = { + "case_id": _LEGACY_IF_CASE_ID, + "logical_operator": data.get("logical_operator") or _DEFAULT_LOGICAL_OPERATOR, + "conditions": data["conditions"], + } + return {**data, "cases": [case]} + + +# Rewrites of old shapes to the one the editor reads, applied before the empty defaults. +_NORMALIZERS: Final[Mapping[str, Callable[[Mapping[str, Any]], Mapping[str, Any]]]] = { + BuiltinNodeTypes.IF_ELSE: _cases_from_legacy_conditions, +} + + +def _single_model(candidates: Iterable[object]) -> type[BaseModel] | None: + models = [c for c in candidates if isinstance(c, type) and issubclass(c, BaseModel)] + return models[0] if len(models) == 1 else None + + +@dataclass(frozen=True) +class _SubModels: + """The model of a field's mapping value (direct or optional), and of each mapping in its list value.""" + + value: type[BaseModel] | None + item: type[BaseModel] | None + + +def _sub_models(annotation: object) -> _SubModels: + arms = get_args(annotation) if get_origin(annotation) in (Union, UnionType) else (annotation,) + is_sequence = get_origin(annotation) in (list, Sequence) + return _SubModels(value=_single_model(arms), item=_single_model(get_args(annotation)) if is_sequence else None) + + +def _fill(model: type[BaseModel], value: Mapping[str, Any]) -> dict[str, Any]: + # graph_diff fills stored graphs that were never validated, and some graphon validators raise KeyError and the + # like on bad data instead of a ValidationError; a sub-object its model refuses is kept as it is. + try: + filled = model.model_validate(value).model_dump(mode="json") + except Exception: + return dict(value) + return {**filled, **value} + + +def complete_sub_models(model: type[BaseModel], data: Mapping[str, Any]) -> dict[str, Any]: + """Add the keys each sub-object's model defaults; keys already present win.""" + fields = model.model_fields + completed = dict(data) + for key, value in data.items(): + field = fields.get(key) + if field is None: + continue + models = _sub_models(field.annotation) + if isinstance(value, Mapping) and models.value: + completed[key] = _fill(models.value, value) + elif isinstance(value, list) and models.item: + item_model = models.item + completed[key] = [_fill(item_model, item) if isinstance(item, Mapping) else item for item in value] + return completed + + +def editor_defaults(data: Mapping[str, Any]) -> dict[str, Any]: + """Old shapes rewritten and the empty containers the editor reads added; keys already present win.""" + node_type = data.get("type") + if not isinstance(node_type, str): + return dict(data) + normalize = _NORMALIZERS.get(node_type) + if normalize is not None: + data = normalize(data) + return {**copy.deepcopy(dict(EDITOR_EMPTY.get(node_type, {}))), **data} + + +def fill_node_data(data: Mapping[str, Any]) -> dict[str, Any]: + filled = editor_defaults(data) + node_type = filled.get("type") + if not isinstance(node_type, str): + return filled + try: + node_class = resolve_workflow_node_class( + node_type=node_type, node_version=str(filled.get("version", "1")), node_data=filled + ) + except ValueError: + return filled + return complete_sub_models(node_data_type(node_type, node_class), filled) + + +def fill_graph(graph: Mapping[str, Any]) -> dict[str, Any]: + nodes = graph.get("nodes") + if not isinstance(nodes, list): + return dict(graph) + return { + **graph, + "nodes": [ + {**node, "data": fill_node_data(node["data"])} + if isinstance(node, Mapping) and isinstance(node.get("data"), Mapping) + else node + for node in nodes + ], + } diff --git a/api/services/workflow_event_snapshot_service.py b/api/services/workflow_event_snapshot_service.py index 3ebe590a9e6..0813e43a5d9 100644 --- a/api/services/workflow_event_snapshot_service.py +++ b/api/services/workflow_event_snapshot_service.py @@ -35,9 +35,11 @@ from core.workflow.human_input_policy import ( resolve_human_input_pause_reason_inputs, resolve_variable_select_input_options, ) +from core.workflow.nodes.human_input.enums import HumanInputFormStatus from core.workflow.nodes.human_input.pause_reason import ( DifyHITLEventType, HumanInputRequired, + PauseReason, ) from graphon.entities import WorkflowStartReason from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus @@ -346,11 +348,15 @@ def _build_snapshot_events( _apply_message_context(node_finished, message_context) events.append(node_finished) + pause_reasons: list[PauseReason] = [] if workflow_run.status == WorkflowExecutionStatus.PAUSED and pause_entity is not None: + pause_reasons = _filter_open_pause_reasons(pause_entity.get_pause_reasons(), session_maker=session_maker) + + if pause_reasons: for human_input_event in _build_human_input_required_events( workflow_run_id=workflow_run.id, task_id=task_id, - pause_entity=pause_entity, + pause_reasons=pause_reasons, session_maker=session_maker, human_input_surface=human_input_surface, variable_pool=variable_pool, @@ -362,7 +368,7 @@ def _build_snapshot_events( workflow_run=workflow_run, workflow_run_id=workflow_run.id, task_id=task_id, - pause_entity=pause_entity, + pause_reasons=pause_reasons, resumption_context=resumption_context, session_maker=session_maker, human_input_surface=human_input_surface, @@ -433,17 +439,42 @@ def _build_node_started_event( return response.to_ignore_detail_dict() +def _filter_open_pause_reasons( + reasons: Sequence[PauseReason], + *, + session_maker: sessionmaker[Session] | None, +) -> list[PauseReason]: + """Drop human-input reasons whose form is no longer waiting. + + Submitting a form only queues the resume, so the run stays paused until a worker picks it up. + Replaying that form would ask again for an answer that was already given. Without a session the + form status is unknown, so every reason is kept. + """ + form_ids = [reason.form_id for reason in reasons if isinstance(reason, HumanInputRequired)] + if not form_ids or session_maker is None: + return list(reasons) + + stmt = select(HumanInputForm.id).where( + HumanInputForm.id.in_(form_ids), + HumanInputForm.status == HumanInputFormStatus.WAITING, + ) + with session_maker() as session: + waiting_form_ids = {str(form_id) for form_id in session.scalars(stmt)} + return [ + reason for reason in reasons if not isinstance(reason, HumanInputRequired) or reason.form_id in waiting_form_ids + ] + + def _build_human_input_required_events( *, workflow_run_id: str, task_id: str, - pause_entity: WorkflowPauseEntity, + pause_reasons: Sequence[PauseReason], session_maker: sessionmaker[Session] | None, human_input_surface: HumanInputSurface | None, variable_pool: ReadOnlyVariablePool | None, ) -> list[dict[str, Any]]: - reasons = pause_entity.get_pause_reasons() - human_input_form_ids = [reason.form_id for reason in reasons if isinstance(reason, HumanInputRequired)] + human_input_form_ids = [reason.form_id for reason in pause_reasons if isinstance(reason, HumanInputRequired)] expiration_times_by_form_id: dict[str, int] = {} display_in_ui_by_form_id: dict[str, bool] = {} @@ -467,7 +498,7 @@ def _build_human_input_required_events( ) events: list[dict[str, Any]] = [] - for reason in reasons: + for reason in pause_reasons: if not isinstance(reason, HumanInputRequired): continue @@ -557,7 +588,7 @@ def _build_pause_event( workflow_run: WorkflowRun, workflow_run_id: str, task_id: str, - pause_entity: WorkflowPauseEntity, + pause_reasons: Sequence[PauseReason], resumption_context: WorkflowResumptionContext | None, session_maker: sessionmaker[Session] | None, human_input_surface: HumanInputSurface | None = None, @@ -571,7 +602,7 @@ def _build_pause_event( variable_pool = state.variable_pool resolved_pause_reasons = resolve_human_input_pause_reason_inputs( - pause_entity.get_pause_reasons(), + pause_reasons, variable_pool=variable_pool, ) paused_nodes = list( diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 9404b34c4c9..da24ece5dd3 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -262,7 +262,7 @@ class WorkflowService: .limit(1) ) - def _get_draft_workflow_for_update(self, app_model: App, *, session: Session) -> Workflow | None: + def get_draft_workflow_for_update(self, app_model: App, *, session: Session) -> Workflow | None: """Return the app draft while holding its row lock for the caller's transaction.""" return session.scalar( select(Workflow) @@ -354,9 +354,14 @@ class WorkflowService: limit: int, user_id: str | None, named_only: bool = False, + include_draft: bool = True, ) -> tuple[Sequence[Workflow], bool]: """ Get published workflow with pagination + + The console lists the draft alongside published versions (``include_draft=True``, + the default); callers that only want published versions, where the draft would + break restore or ordering, pass ``include_draft=False``. """ if not app_model.workflow_id: return [], False @@ -384,6 +389,9 @@ class WorkflowService: if named_only: stmt = stmt.where(Workflow.marked_name != "") + if not include_draft: + stmt = stmt.where(Workflow.version != Workflow.VERSION_DRAFT) + workflows = session.scalars(stmt).all() has_more = len(workflows) > limit @@ -427,7 +435,7 @@ class WorkflowService: raise ValueError("Deleted environment variable ids require an environment variable patch.") # fetch draft workflow by app_model - workflow = self._get_draft_workflow_for_update(app_model=app_model, session=session) + workflow = self.get_draft_workflow_for_update(app_model=app_model, session=session) if workflow and workflow.unique_hash != unique_hash: raise WorkflowHashNotEqualError() @@ -548,7 +556,7 @@ class WorkflowService: variables they do not touch. Existing variables keep their order and new variables are appended. The transaction is committed before this method returns. """ - workflow = self._get_draft_workflow_for_update(app_model=app_model, session=session) + workflow = self.get_draft_workflow_for_update(app_model=app_model, session=session) if not workflow: raise ValueError("No draft workflow found.") @@ -772,93 +780,103 @@ class WorkflowService: ) for node in nodes: - node_data = node.get("data", {}) - node_type = node_data.get("type") - node_id = node.get("id", "unknown") + self.validate_node_credentials(workflow.tenant_id, node, environment_variables, session=session) - try: - # Extract and validate credentials based on node type - if node_type == "tool": - credential_id = node_data.get("credential_id") - provider = node_data.get("provider_id") - if provider: - if credential_id: - # Check specific credential - from core.helper.credential_utils import check_credential_policy_compliance + def validate_node_credentials( + self, + tenant_id: str, + node: Mapping[str, Any], + environment_variables: Mapping[str, VariableBase], + *, + session: Session, + ) -> None: + """Publish's credential check for one node. - check_credential_policy_compliance( - credential_id=credential_id, - provider=provider, - credential_type=PluginCredentialType.TOOL, - ) - else: - # Check default workspace credential for this provider - self._check_default_tool_credential(workflow.tenant_id, provider, session=session) + :raises ValueError: naming the node when a credential it needs violates policy or is unusable + """ + node_data = node.get("data", {}) + node_type = node_data.get("type") + node_id = node.get("id", "unknown") - elif node_type == "agent": - agent_params = node_data.get("agent_parameters", {}) + try: + # Extract and validate credentials based on node type + if node_type == "tool": + credential_id = node_data.get("credential_id") + provider = node_data.get("provider_id") + if provider: + if credential_id: + # Check specific credential + from core.helper.credential_utils import check_credential_policy_compliance - model_config = agent_params.get("model", {}).get("value", {}) - if model_config.get("provider") and model_config.get("model"): - self._validate_llm_model_config( - workflow.tenant_id, model_config["provider"], model_config["model"] - ) - - # Validate load balancing credentials for agent model if load balancing is enabled - agent_model_node_data = {"model": model_config} - self._validate_load_balancing_credentials( - workflow, agent_model_node_data, node_id, session=session - ) - - # Validate agent tools - tools = agent_params.get("tools", {}).get("value", []) - for tool in tools: - # Agent tools store provider in provider_name field - provider = tool.get("provider_name") - credential_id = tool.get("credential_id") - if provider: - if credential_id: - from core.helper.credential_utils import check_credential_policy_compliance - - check_credential_policy_compliance(credential_id, provider, PluginCredentialType.TOOL) - else: - self._check_default_tool_credential(workflow.tenant_id, provider, session=session) - - elif node_type in ["llm", "knowledge_retrieval", "parameter_extractor", "question_classifier"]: - validation_node_data = node_data - model_config = node_data.get("model", {}) - if node_type == "llm" and should_resolve_llm_model_selector(node_data.get("model_selector")): - selector = parse_llm_model_selector(node_data["model_selector"]) - variable = environment_variables.get(selector[1]) - if not isinstance(variable, LLMEnvironmentVariable): - raise ValueError( - f"LLM environment variable '{selector[1]}' was not found or is not an LLM variable" - ) - resolved_model = resolve_llm_model_config( - node_model=ModelConfig.model_validate(model_config), - variable_name=selector[1], - variable_value=variable.value, - ) - model_config = resolved_model.model_dump(mode="json") - validation_node_data = {**node_data, "model": model_config} - provider = model_config.get("provider") - model_name = model_config.get("name") - - if provider and model_name: - # Validate that the provider+model combination can fetch valid credentials - self._validate_llm_model_config(workflow.tenant_id, provider, model_name) - # Validate load balancing credentials if load balancing is enabled - self._validate_load_balancing_credentials( - workflow, validation_node_data, node_id, session=session + check_credential_policy_compliance( + credential_id=credential_id, + provider=provider, + credential_type=PluginCredentialType.TOOL, ) else: - raise ValueError(f"Node {node_id} ({node_type}): Missing provider or model configuration") + # Check default workspace credential for this provider + self._check_default_tool_credential(tenant_id, provider, session=session) - except Exception as e: - if isinstance(e, ValueError): - raise e + elif node_type == "agent": + agent_params = node_data.get("agent_parameters", {}) + + model_config = agent_params.get("model", {}).get("value", {}) + if model_config.get("provider") and model_config.get("model"): + self._validate_llm_model_config(tenant_id, model_config["provider"], model_config["model"]) + + # Validate load balancing credentials for agent model if load balancing is enabled + agent_model_node_data = {"model": model_config} + self._validate_load_balancing_credentials( + tenant_id, agent_model_node_data, node_id, session=session + ) + + # Validate agent tools + tools = agent_params.get("tools", {}).get("value", []) + for tool in tools: + # Agent tools store provider in provider_name field + provider = tool.get("provider_name") + credential_id = tool.get("credential_id") + if provider: + if credential_id: + from core.helper.credential_utils import check_credential_policy_compliance + + check_credential_policy_compliance(credential_id, provider, PluginCredentialType.TOOL) + else: + self._check_default_tool_credential(tenant_id, provider, session=session) + + elif node_type in ["llm", "knowledge_retrieval", "parameter_extractor", "question_classifier"]: + validation_node_data = node_data + model_config = node_data.get("model", {}) + if node_type == "llm" and should_resolve_llm_model_selector(node_data.get("model_selector")): + selector = parse_llm_model_selector(node_data["model_selector"]) + variable = environment_variables.get(selector[1]) + if not isinstance(variable, LLMEnvironmentVariable): + raise ValueError( + f"LLM environment variable '{selector[1]}' was not found or is not an LLM variable" + ) + resolved_model = resolve_llm_model_config( + node_model=ModelConfig.model_validate(model_config), + variable_name=selector[1], + variable_value=variable.value, + ) + model_config = resolved_model.model_dump(mode="json") + validation_node_data = {**node_data, "model": model_config} + provider = model_config.get("provider") + model_name = model_config.get("name") + + if provider and model_name: + # Validate that the provider+model combination can fetch valid credentials + self._validate_llm_model_config(tenant_id, provider, model_name) + # Validate load balancing credentials if load balancing is enabled + self._validate_load_balancing_credentials(tenant_id, validation_node_data, node_id, session=session) else: - raise ValueError(f"Node {node_id} ({node_type}): {str(e)}") + raise ValueError(f"Node {node_id} ({node_type}): Missing provider or model configuration") + + except Exception as e: + if isinstance(e, ValueError): + raise e + else: + raise ValueError(f"Node {node_id} ({node_type}): {str(e)}") def _validate_llm_model_config(self, tenant_id: str, provider: str, model_name: str) -> None: """ @@ -957,12 +975,12 @@ class WorkflowService: raise ValueError(f"Failed to validate default credential for tool provider {provider}: {str(e)}") def _validate_load_balancing_credentials( - self, workflow: Workflow, node_data: dict[str, Any], node_id: str, *, session: Session + self, tenant_id: str, node_data: Mapping[str, Any], node_id: str, *, session: Session ) -> None: """ Validate load balancing credentials for a workflow node. - :param workflow: The workflow being validated + :param tenant_id: The workspace that owns the workflow :param node_data: The node data containing model configuration :param node_id: The node ID for error reporting :raises ValueError: If load balancing credentials violate policy compliance @@ -976,11 +994,9 @@ class WorkflowService: return # No model config to validate # Check if this model has load balancing enabled - if self._is_load_balancing_enabled(workflow.tenant_id, provider, model_name): + if self._is_load_balancing_enabled(tenant_id, provider, model_name): # Get all load balancing configurations for this model - load_balancing_configs = self._get_load_balancing_configs( - workflow.tenant_id, provider, model_name, session=session - ) + load_balancing_configs = self._get_load_balancing_configs(tenant_id, provider, model_name, session=session) # Validate each load balancing configuration try: for config in load_balancing_configs: diff --git a/api/services/workflow_variable_reference_validator.py b/api/services/workflow_variable_reference_validator.py index 8ac841724ea..d2e5cd6e6e2 100644 --- a/api/services/workflow_variable_reference_validator.py +++ b/api/services/workflow_variable_reference_validator.py @@ -1,10 +1,12 @@ from __future__ import annotations +import json +import logging import re from collections import defaultdict, deque -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from dataclasses import dataclass -from typing import Any +from typing import Any, Final from core.trigger.constants import TRIGGER_NODE_TYPES from core.workflow.nodes.human_input.constants import TIMEOUT_HANDLE @@ -19,19 +21,65 @@ _REFERENCE_EXEMPT_NODE_TYPES: frozenset[str] = frozenset( } ) -_BRANCH_NODE_TYPES: frozenset[str] = frozenset( - { - BuiltinNodeTypes.IF_ELSE, - BuiltinNodeTypes.QUESTION_CLASSIFIER, - BuiltinNodeTypes.HUMAN_INPUT, - } -) +SOURCE_HANDLE: Final = "source" +_ELSE_HANDLE: Final = "false" +_LEGACY_IF_HANDLE: Final = "true" +_FAIL_BRANCH_HANDLE: Final = ErrorStrategy.FAIL_BRANCH.value _TEMPLATE_REFERENCE_PATTERN = re.compile( r"\{\{#([a-zA-Z0-9_]{1,50})\.[a-zA-Z_][a-zA-Z0-9_]{0,29}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){0,9}#\}\}" ) _MAX_REPORTED_ISSUES = 10 +logger = logging.getLogger(__name__) + + +def _ids(items: object, key: str) -> frozenset[str]: + if not isinstance(items, list): + return frozenset() + return frozenset(str(item[key]) for item in items if isinstance(item, Mapping) and item.get(key) is not None) + + +def _if_else_handles(data: Mapping[str, Any]) -> frozenset[str]: + cases = data.get("cases") + return (_ids(cases, "case_id") if isinstance(cases, list) else frozenset({_LEGACY_IF_HANDLE})) | {_ELSE_HANDLE} + + +def _question_classifier_handles(data: Mapping[str, Any]) -> frozenset[str]: + return _ids(data.get("classes"), "id") + + +def _human_input_handles(data: Mapping[str, Any]) -> frozenset[str]: + return _ids(data.get("user_actions"), "id") | {TIMEOUT_HANDLE} + + +def _agent_route_handles(data: Mapping[str, Any]) -> frozenset[str] | None: + routes = data.get("agent_output_routes") + if not isinstance(routes, Mapping) or routes.get("enabled") is not True: + return None + return _ids(routes.get("routes"), "id") + + +_BRANCH_HANDLES: Final[Mapping[str, Callable[[Mapping[str, Any]], frozenset[str] | None]]] = { + BuiltinNodeTypes.IF_ELSE: _if_else_handles, + BuiltinNodeTypes.QUESTION_CLASSIFIER: _question_classifier_handles, + BuiltinNodeTypes.HUMAN_INPUT: _human_input_handles, + BuiltinNodeTypes.AGENT: _agent_route_handles, +} + + +def branch_handles(data: Mapping[str, Any]) -> frozenset[str] | None: + """The source handles a node chooses one of at run time; None for a node that follows every outgoing edge. + + A fail-branch node can also leave by ``fail-branch``: a branch node beside its own handles, any other node + beside ``source``. + """ + read = _BRANCH_HANDLES.get(str(data.get("type"))) + handles = read(data) if read else None + if data.get("error_strategy") != ErrorStrategy.FAIL_BRANCH: + return handles + return (handles if handles is not None else frozenset({SOURCE_HANDLE})) | {_FAIL_BRANCH_HANDLE} + @dataclass(frozen=True) class VariableReferenceIssue: @@ -47,7 +95,7 @@ def validate_variable_references(graph: Mapping[str, Any]) -> list[VariableRefer """Flag references where the consumer can run without the producer. Uses engine execution semantics: nodes fire on any active inbound edge; branch - nodes (if-else / question-classifier / human-input / fail-branch) take one handle + nodes (see ``branch_handles``) take one handle (possibly unwired); other nodes fan out all outbound edges. Returns [] when clean. """ nodes = graph.get("nodes") or [] @@ -96,26 +144,10 @@ def validate_variable_references(graph: Mapping[str, Any]) -> list[VariableRefer if node_parent[nid] is None and node_type[nid] in (BuiltinNodeTypes.START, *TRIGGER_NODE_TYPES) ] reachable = _reachable_from(entries, successors) - exclusive = { - nid - for nid in node_ids - if node_type[nid] in _BRANCH_NODE_TYPES or node_data[nid].get("error_strategy") == ErrorStrategy.FAIL_BRANCH - } + handles_by_branch = {nid: handles for nid in node_ids if (handles := branch_handles(node_data[nid])) is not None} + exclusive = set(handles_by_branch) # A selectable handle can be unwired. It still lets the branch skip a producer. - for nid in exclusive: - data = node_data[nid] - handles: list[str] = [] - if node_type[nid] == BuiltinNodeTypes.IF_ELSE: - cases = data.get("cases") - handles = [case["case_id"] for case in cases] if isinstance(cases, list) else ["true"] - handles.append("false") - elif node_type[nid] == BuiltinNodeTypes.QUESTION_CLASSIFIER: - handles = [item["id"] for item in data.get("classes", [])] - elif node_type[nid] == BuiltinNodeTypes.HUMAN_INPUT: - handles = [action["id"] for action in data.get("user_actions", [])] - handles.append(TIMEOUT_HANDLE) - if data.get("error_strategy") == ErrorStrategy.FAIL_BRANCH: - handles.extend(["source", "fail-branch"]) + for nid, handles in handles_by_branch.items(): for handle in handles: out_targets_by_handle[nid].setdefault(handle, []) @@ -169,6 +201,21 @@ def format_variable_reference_errors(issues: Sequence[VariableReferenceIssue]) - ) +def advisory_variable_reference_warning(graph_text: str | None) -> str | None: + """Return a non-blocking publish warning. A checker failure must not fail publish.""" + if not graph_text: + return None + try: + graph = json.loads(graph_text) + if not isinstance(graph, dict): + return None + issues = validate_variable_references(graph) + return format_variable_reference_errors(issues) if issues else None + except Exception: + logger.warning("Skipped advisory variable reference check", exc_info=True) + return None + + def _reachable_from(starts: Sequence[str], successors: Mapping[str, list[str]]) -> set[str]: visited: set[str] = set() queue: deque[str] = deque(starts) diff --git a/api/services/workspace/service.py b/api/services/workspace/service.py index fe4dd5a87af..80ac05b24a8 100644 --- a/api/services/workspace/service.py +++ b/api/services/workspace/service.py @@ -78,9 +78,6 @@ class WorkspaceService: def find_membership(self, account_id: str, workspace_id: str) -> WorkspaceSnapshot | None: return self._workspaces.find_membership(account_id, workspace_id) - def switch_membership(self, account_id: str, workspace_id: str) -> WorkspaceSnapshot: - return self._workspaces.switch(account_id=account_id, workspace_id=workspace_id) - def iter_member_account_id_batches(self, workspace_id: str, batch_size: int) -> Iterator[tuple[str, ...]]: if batch_size <= 0: raise ValueError("batch_size must be positive") diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py index 89848a00a56..5a41f79544b 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py @@ -12,7 +12,7 @@ from faker import Faker from flask import Flask from sqlalchemy.orm import Session -from controllers.common.errors import NotFoundError +from controllers.openapi._errors import DraftNotFound from controllers.openapi._models import AppDslExportQuery, AppDslExportResponse, AppDslImportPayload from controllers.openapi.app_dsl import ( AppDslCheckDependenciesApi, @@ -242,7 +242,7 @@ class TestDslExport: self, app: Flask, db_session_with_containers: Session ) -> None: """A workflow app with no draft workflow can't be exported → the service - raises WorkflowNotFoundError, which the controller maps to 404.""" + raises WorkflowNotFoundError, which the controller maps to 404 draft_not_found.""" app_model = App( id=str(uuid4()), tenant_id=str(uuid4()), @@ -257,7 +257,7 @@ class TestDslExport: api = AppDslExportApi() with ( app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dsl"), - pytest.raises(NotFoundError, match="Missing draft workflow configuration") as exc_info, + pytest.raises(DraftNotFound) as exc_info, ): cast(_Endpoint[tuple[AppDslExportResponse, int]], api.get).__handler__( api, diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py index b082c4197d4..bde2c3da93c 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound from controllers.openapi._models import WorkspaceListQuery -from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi, WorkspaceSwitchApi +from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi from models import Account from models.account import TenantAccountRole from tests.test_containers_integration_tests.controllers.openapi.conftest import add_tenant_for_account, context_for @@ -33,9 +33,6 @@ class TestWorkspacesList: only = result.data[0] assert only.role == TenantAccountRole.OWNER.value assert only.status == "normal" - # Newly-created owner membership is not yet "current"; switching flips it - # (see TestWorkspaceSwitch). - assert only.current is False def test_lists_all_joined_workspaces( self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] @@ -72,7 +69,6 @@ class TestWorkspaceDetail: assert detail.id == tenant.id assert detail.role == TenantAccountRole.OWNER.value - assert detail.current is False assert detail.created_at is not None def test_non_member_detail_is_404_not_403( @@ -97,56 +93,3 @@ class TestWorkspaceDetail: ), someone_elses_ws.id, ) - - -class TestWorkspaceSwitch: - def test_switch_sets_current_and_persists( - self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] - ) -> None: - account = make_account() - owner_tenant = account.current_tenant - assert owner_tenant is not None - target = add_tenant_for_account( - account, session=db_session_with_containers, role="normal", name="Switch Target" - ) - - api = WorkspaceSwitchApi() - with app.test_request_context(f"/openapi/v1/workspaces/{target.id}/switch", method="POST"): - detail = api.post.__handler__( - api, - context_for(account, session=db_session_with_containers, view_args={"workspace_id": target.id}), - target.id, - ) - - # Response reflects the post-switch state. - assert detail.id == target.id - assert detail.current is True - - # And the switch persisted: the previously-current owner workspace is no - # longer current (verified through the real read path). - listing_api = WorkspacesApi() - with app.test_request_context("/openapi/v1/workspaces"): - listing = listing_api.get.__handler__( - listing_api, context_for(account, session=db_session_with_containers), query=WorkspaceListQuery() - ) - by_id = {w.id: w for w in listing.data} - assert by_id[target.id].current is True - assert by_id[owner_tenant.id].current is False - - def test_switch_to_non_member_workspace_is_404( - self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] - ) -> None: - account = make_account() - outsider_ws = make_account().current_tenant - assert outsider_ws is not None - - api = WorkspaceSwitchApi() - with app.test_request_context(f"/openapi/v1/workspaces/{outsider_ws.id}:switch", method="POST"): - with pytest.raises(NotFound): - api.post.__handler__( - api, - context_for( - account, session=db_session_with_containers, view_args={"workspace_id": outsider_ws.id} - ), - outsider_ws.id, - ) diff --git a/api/tests/unit_tests/configs/test_auth_config.py b/api/tests/unit_tests/configs/test_auth_config.py new file mode 100644 index 00000000000..d310161bb4a --- /dev/null +++ b/api/tests/unit_tests/configs/test_auth_config.py @@ -0,0 +1,24 @@ +import pytest + +from configs.feature import AuthConfig +from tests.unit_tests.configs._isolated_settings import InitSettingsOnly + + +class _IsolatedAuthConfig(InitSettingsOnly, AuthConfig): + pass + + +def test_oauth_ttl_days_defaults_to_fourteen() -> None: + config = _IsolatedAuthConfig() + + assert config.OAUTH_TTL_DAYS == 14 + + +@pytest.mark.parametrize( + ("configured", "expected"), + [("30", 30), ("1", 1), ("365", 365), ("0", 1), ("-5", 1), ("500", 365), ("invalid", 14), ("", 14)], +) +def test_oauth_ttl_days_clamps_or_falls_back_instead_of_failing(configured: str, expected: int) -> None: + config = _IsolatedAuthConfig(OAUTH_TTL_DAYS=configured) + + assert expected == config.OAUTH_TTL_DAYS diff --git a/api/tests/unit_tests/controllers/conftest.py b/api/tests/unit_tests/controllers/conftest.py index fbe628463b1..cd8c5e8b1dd 100644 --- a/api/tests/unit_tests/controllers/conftest.py +++ b/api/tests/unit_tests/controllers/conftest.py @@ -28,6 +28,7 @@ from services.enterprise.enterprise_service import EnterpriseService from services.tag_application_service import TagApplicationService from services.webapp_access_adapters import EnterpriseWebAppAccessPolicyGateway from services.webapp_access_query_service import WebAppAccessQueryService +from services.workflow.dsl_import import DslImportService from tests.unit_tests.controllers.error_contract import check_response, track_external_apis track_external_apis() @@ -122,6 +123,7 @@ class AppQueryTestServices: console: ConsoleAppService imports: AppImportService exports: AppExportService + dsl_imports: DslImportService discovery: AppDiscoveryService @@ -146,6 +148,7 @@ def app_query_services( console=app_services.console, imports=app_services.imports, exports=app_services.exports, + dsl_imports=app_services.dsl_imports, discovery=app_services.discovery, ), webapp_access=WebAppAccessQueryService( diff --git a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py index 64803e4cf1a..5485070f512 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import Forbidden from controllers.console.app import app_import as controller -from controllers.console.app.error import AppNotFoundError +from controllers.console.app.error import AppNotFoundError, DraftWorkflowNotSync from extensions.ext_redis import redis_client from machinery.context import RequestContext from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole @@ -20,6 +20,7 @@ from services.app.console_gateway import EnterpriseConsoleAppAccess from services.app_dsl_service import AppDslService, PendingData from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import Import, ImportStatus +from services.errors.app import DraftChangedError from services.errors.base import NoPermissionError from services.plugin.dependencies_analysis import DependenciesAnalysisService from services.system_feature_service import SystemFeatureService @@ -218,5 +219,21 @@ def test_permission_errors_are_mapped_at_http_boundary( unwrap(api.post)(api, context) +def test_confirming_an_import_whose_draft_changed_is_a_conflict(app_query_services, app: Flask, monkeypatch) -> None: + """A pending openapi import carries a draft_hash; a console confirm after the draft changed must not 500.""" + + def changed(*_args, **_kwargs): + raise DraftChangedError() + + monkeypatch.setattr(app_query_services.apps.console, "confirm_import", changed) + context = RequestContext("request", None, "actor", "workspace") + with app.test_request_context(method="POST"): + api = controller.AppImportConfirmApi() + with pytest.raises(DraftWorkflowNotSync) as exc_info: + unwrap(api.post)(api, context, import_id="import-1") + + assert exc_info.value.code == 409 + + def test_import_response_schema_hides_creation_marker() -> None: assert "app_created" not in controller.console_ns.models[Import.__name__].__schema__["properties"] diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow.py b/api/tests/unit_tests/controllers/console/app/test_workflow.py index 52a9172605f..55117245293 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow.py @@ -23,6 +23,7 @@ from graphon.variables.variables import RAGPipelineVariable from models.account import Account from models.model import App, AppMode from models.workflow import Workflow, WorkflowType +from services import workflow_variable_reference_validator as variable_reference_validator from services.app.access import AppAccessFilter from tests.unit_tests.config_override import apply_config_overrides from tests.unit_tests.controllers.error_body import raised_error @@ -168,10 +169,12 @@ def test_publish_workflow_returns_success( graph={"clean": "{}", "empty": None, "non-object": "[]", "invalid-json": "{"}.get(advisory, json.dumps(graph)), ) if advisory == "checker-error": - monkeypatch.setattr(workflow_module, "validate_variable_references", Mock(side_effect=RuntimeError("checker"))) + monkeypatch.setattr( + variable_reference_validator, "validate_variable_references", Mock(side_effect=RuntimeError("checker")) + ) elif advisory == "formatter-error": monkeypatch.setattr( - workflow_module, "format_variable_reference_errors", Mock(side_effect=RuntimeError("format")) + variable_reference_validator, "format_variable_reference_errors", Mock(side_effect=RuntimeError("format")) ) session = Mock() session.get.return_value = app_model diff --git a/api/tests/unit_tests/controllers/openapi/auth/_world.py b/api/tests/unit_tests/controllers/openapi/auth/_world.py index e1db34fa8f2..98985e191c8 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/_world.py +++ b/api/tests/unit_tests/controllers/openapi/auth/_world.py @@ -65,13 +65,15 @@ def sso_subject(**overrides: object) -> ExternalSsoSubject: return ExternalSsoSubject(make_auth(TokenType.OAUTH_EXTERNAL_SSO, **overrides)) -def make_app(*, app_id: str = APP_ID, tenant_id: str = TENANT_ID, enable_api: bool = True) -> App: +def make_app( + *, app_id: str = APP_ID, tenant_id: str = TENANT_ID, enable_api: bool = True, mode: AppMode = AppMode.CHAT +) -> App: return App( id=app_id, tenant_id=tenant_id, name="OpenAPI app", description="", - mode=AppMode.CHAT, + mode=mode, icon_type=IconType.EMOJI, icon="robot", icon_background="#FFFFFF", diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py b/api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py index 156e9fb6f0c..36bd76cc894 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py @@ -9,10 +9,10 @@ from flask import Flask from sqlalchemy import event from sqlalchemy.engine import Engine from sqlalchemy.orm import Session -from werkzeug.exceptions import Forbidden, Unauthorized +from werkzeug.exceptions import HTTPException, Unauthorized from controllers.openapi._catalog import CATALOG_HEADER, catalog_for -from controllers.openapi._errors import CatalogStale +from controllers.openapi._errors import CatalogStale, ServiceApiDisabled from controllers.openapi.auth import loaders from controllers.openapi.auth.context import Context from controllers.openapi.auth.pipelines import ( @@ -225,7 +225,7 @@ def test_the_requirements_that_share_a_datum_fetch_it_once( ("requirements", "enable_api", "webapp_auth", "message"), [ ((CheckSubject(allowed=[AccountSubject]),), True, False, "unsupported_token_type"), - ((CheckAppApiEnabled(),), False, False, "service_api_disabled"), + ((CheckAppApiEnabled(),), False, False, ServiceApiDisabled.description), ((CheckAppAccess(),), True, True, "subject_not_allowed_for_access_mode"), ], ids=["wrong subject (FIRST)", "api disabled (EARLY)", "webapp acl (NORMAL)"], @@ -254,7 +254,7 @@ def test_a_refused_sso_request_never_creates_an_end_user( with app.test_request_context(f"/openapi/v1/apps/{APP_ID}:run", headers=_current_catalog(app)): with patch(FEATURES, return_value=system_features(webapp_auth=webapp_auth)): with patch(ACCESS_MODE, return_value=webapp_settings(WebAppAccessMode.PRIVATE_ALL.value)): - with pytest.raises(Forbidden, match=message): + with pytest.raises(HTTPException, match=message): _run(ExternalSsoPipeline(), subject, ctx, sqlite_session, requirements=requirements) assert ctx._caller is None diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_requirements.py b/api/tests/unit_tests/controllers/openapi/auth/test_requirements.py index 75505a94a15..ac89a860edb 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_requirements.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_requirements.py @@ -9,6 +9,10 @@ from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, NotFound from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission +from controllers.openapi._contract import op_of +from controllers.openapi._errors import OpenApiErrorCode, OpenApiErrorFormatter, ServiceApiDisabled +from controllers.openapi._models import ServiceApiPatch +from controllers.openapi.app_info import AgentServiceApiApi, ServiceApiApi from controllers.openapi.auth.context import RouteContractError from controllers.openapi.auth.requirements import ( CheckAppAccess, @@ -20,6 +24,7 @@ from controllers.openapi.auth.requirements import ( ) from controllers.openapi.auth.subjects import AccountSubject from enums import DeploymentEdition +from models import AppMode from models.account import AccountStatus, TenantAccountRole from services.enterprise.enterprise_service import WebAppAccessMode, WebAppSettings from services.entities.feature_entities import ( @@ -98,6 +103,38 @@ def test_an_app_requirement_off_an_app_route_is_a_wiring_bug( CheckAppApiEnabled().run(subject, make_ctx(sqlite_session, subject=subject), sqlite_session) +@pytest.mark.parametrize( + ("mode", "view"), + [ + (AppMode.CHAT, ServiceApiApi.patch), + (AppMode.WORKFLOW, ServiceApiApi.patch), + (AppMode.AGENT, AgentServiceApiApi.patch), + ], +) +def test_a_disabled_service_api_hints_the_op_that_turns_it_on( + sqlite_session: Session, mode: AppMode, view: Callable[..., object] +) -> None: + persist(sqlite_session, make_app(enable_api=False, mode=mode)) + subject = account_subject() + + with pytest.raises(ServiceApiDisabled) as raised: + CheckAppApiEnabled().run(subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session) + + body = OpenApiErrorFormatter().finalize(raised.value, {}, raised.value.code or 0) + assert (body["status"], body["code"]) == (403, OpenApiErrorCode.SERVICE_API_DISABLED) + [hint] = body["hints"] + assert hint["op"] == op_of(view) + assert hint["input"] == {"app_id": APP_ID, "enabled": True} + ServiceApiPatch.model_validate({k: v for k, v in hint["input"].items() if k != "app_id"}) + + +def test_an_enabled_service_api_passes(sqlite_session: Session) -> None: + persist(sqlite_session, make_app(enable_api=True)) + subject = account_subject() + + CheckAppApiEnabled().run(subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session) + + class TestCheckRBACPermission: @staticmethod def _requirement() -> CheckRBACPermission: diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_resource_access.py b/api/tests/unit_tests/controllers/openapi/auth/test_resource_access.py index 5a9e1aea76b..314852da2e3 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_resource_access.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_resource_access.py @@ -233,19 +233,17 @@ def test_workspace_list_returns_only_token_tenant(resource_fixture: ResourceFixt "name": "Token workspace", "role": "", "status": "normal", - "current": True, } -@pytest.mark.parametrize("endpoint", ["detail", "members", "switch"]) +@pytest.mark.parametrize("endpoint", ["detail", "members"]) def test_other_workspace_endpoints_reject_resource_token(resource_fixture: ResourceFixture, endpoint: str) -> None: - from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspaceMembersApi, WorkspaceSwitchApi + from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspaceMembersApi flask_app, _, _, tenant_id, _ = resource_fixture methods = { "detail": WorkspaceByIdApi().get, "members": WorkspaceMembersApi().get, - "switch": WorkspaceSwitchApi().post, } with flask_app.test_request_context( "/workspaces", headers={"Authorization": "Bearer sk-test", CATALOG_HEADER: catalog_for(flask_app)[1]} diff --git a/api/tests/unit_tests/controllers/openapi/test_app_describe_builder.py b/api/tests/unit_tests/controllers/openapi/test_app_describe_builder.py index 2dae8ee13a4..79298358e4b 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_describe_builder.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_describe_builder.py @@ -1,18 +1,32 @@ +from collections.abc import Callable from datetime import datetime +from typing import Protocol, cast import pytest +from flask import Flask from controllers.openapi._app_response import _EMPTY_PARAMETERS, build_app_describe_response from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA +from controllers.openapi._models import AppDescribeQuery, AppDescribeResponse +from controllers.openapi.apps import AppDescribeApi, settings_hints +from controllers.openapi.auth.requirements import CheckWebAppAuthEnterprise +from machinery.context import AppRequestContext from models.model import AppMode from services.app.query_service import AppDescription from services.app_definition_query_service import AppParameterConfig from services.entities.app_entities import AppSummary +from tests.unit_tests.controllers.conftest import ControllerTestServices -def description(config: AppParameterConfig | None) -> AppDescription: +class Endpoint[T](Protocol): + __handler__: Callable[..., T] + + +def description(config: AppParameterConfig | None, mode: str = AppMode.CHAT) -> AppDescription: return AppDescription( - AppSummary("app-id", "tenant-id", "Demo", "d", AppMode.CHAT, "normal", datetime(2026, 1, 1), "maintainer"), + AppSummary( + "app-id", "tenant-id", "Demo", "d", cast(AppMode, mode), "normal", datetime(2026, 1, 1), "maintainer" + ), True, config, ) @@ -53,3 +67,61 @@ def test_empty_parameters_preserve_public_response_contract() -> None: "file_upload": None, "system_parameters": {}, } + + +def test_settings_hints_name_the_mode_ops(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(CheckWebAppAuthEnterprise, "available", staticmethod(lambda: True)) + ops = [hint.op for hint in settings_hints("app-1", AppMode.WORKFLOW)] + assert ops == [ + "describe.app_info.workflow", + "describe.webapp.workflow", + "describe.service_api", + "describe.webapp_access", + ] + agent_ops = [hint.op for hint in settings_hints("app-1", AppMode.AGENT)] + assert agent_ops == [ + "describe.app_info.agent", + "describe.webapp.agent", + "describe.service_api.agent", + "describe.webapp_access.agent", + ] + assert all(hint.input == {"app_id": "app-1"} for hint in settings_hints("app-1", AppMode.CHAT)) + + +def test_settings_hints_skip_web_app_access_without_web_app_auth(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(CheckWebAppAuthEnterprise, "available", staticmethod(lambda: False)) + ops = [hint.op for hint in settings_hints("app-1", AppMode.WORKFLOW)] + assert "describe.webapp_access" not in ops + assert ops + + +def test_shared_builder_adds_no_hints() -> None: + """The builder also serves describe.console_app.external, whose callers can't use account-only settings ops.""" + assert build_app_describe_response(description(None), None).hints == [] + + +def _describe( + app: Flask, services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch, mode: str +) -> AppDescribeResponse: + monkeypatch.setattr(services.apps.discovery, "describe", lambda *_args: description(None, mode)) + api = AppDescribeApi() + with app.test_request_context(): + return cast(Endpoint[AppDescribeResponse], api.get).__handler__( + api, AppRequestContext(tenant_id="tenant-id", app_id="app-id"), "app-id", query=AppDescribeQuery() + ) + + +def test_account_describe_adds_settings_hints( + app: Flask, app_query_services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch +) -> None: + resp = _describe(app, app_query_services, monkeypatch, AppMode.CHAT) + assert [hint.op for hint in resp.hints] == [hint.op for hint in settings_hints("app-id", AppMode.CHAT)] + assert resp.hints + + +def test_account_describe_unknown_mode_has_no_hints( + app: Flask, app_query_services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch +) -> None: + resp = _describe(app, app_query_services, monkeypatch, "mode-from-a-newer-version") + assert resp.hints == [] + assert resp.info is not None diff --git a/api/tests/unit_tests/controllers/openapi/test_app_dsl.py b/api/tests/unit_tests/controllers/openapi/test_app_dsl.py index 07091d84d9d..e4e72e5b2df 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_dsl.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_dsl.py @@ -1,24 +1,29 @@ -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Protocol, cast from uuid import uuid4 import pytest +import yaml from flask import Flask, request from flask_restx import Resource from sqlalchemy.orm import Session, sessionmaker +from werkzeug.exceptions import BadRequest, NotFound -from controllers.common.errors import ForbiddenError +from controllers.common.errors import ForbiddenError, NotFoundError +from controllers.openapi import app_dsl from controllers.openapi._contract import op_of -from controllers.openapi._errors import OpenApiErrorFormatter +from controllers.openapi._errors import DraftChanged, DraftNotFound, DslInvalid, OpenApiErrorFormatter, VersionNotFound from controllers.openapi._models import ( AppDslExportQuery, AppDslExportResponse, AppDslImportPayload, AppDslImportResponse, + DslCheckPayload, + DslCheckResponse, Hint, ) -from controllers.openapi.app_dsl import AppDslExportApi, AppDslImportApi, AppDslImportConfirmApi +from controllers.openapi.app_dsl import AppDslCheckApi, AppDslExportApi, AppDslImportApi, AppDslImportConfirmApi from controllers.openapi.auth.spec import EndpointSpec from libs.external_api import ExternalApi from machinery.context import AppRequestContext, RequestContext @@ -27,8 +32,18 @@ from models.workflow import Workflow, WorkflowType from repositories.app.export_repository import AppExportRepository from services.app.export_service import AppExportService from services.app_dsl_service import AppDslService -from services.entities.dsl_entities import Import, ImportStatus +from services.entities.app_entities import AppExportOptions +from services.entities.dsl_entities import DraftGuardedImportParams, Import, ImportStatus +from services.errors.app import ( + AppDiscoveryNotFoundError, + DraftChangedError, + IsDraftWorkflowError, + WorkflowNotFoundError, +) from services.errors.base import NoPermissionError +from services.workflow import graph_check +from services.workflow.graph_check import IssueCode, IssueSeverity +from services.workflow.graph_diff import current_draft_token, draft_token from tests.unit_tests.controllers.conftest import ControllerTestServices @@ -39,105 +54,12 @@ class _EndpointView(Protocol): __handler__: Callable[..., tuple[AppDslImportResponse, int]] -class _ExportEndpoint(Protocol): +class _ExportView(Protocol): + """Same stand-in, typed for the export endpoint's response.""" + __handler__: Callable[..., tuple[AppDslExportResponse, int]] -@dataclass(frozen=True) -class ExportAppServices: - exports: AppExportService - - -@dataclass(frozen=True) -class ExportServices: - apps: ExportAppServices - - -@pytest.mark.parametrize( - ("missing", "status", "error_code"), - [ - ("app", 404, "not_found"), - ("draft", 404, "not_found"), - ("version", 404, "not_found"), - ("draft_version", 400, "bad_request"), - ], -) -def test_export_errors_have_canonical_http_responses( - sqlite_session: Session, - sqlite_session_factory: sessionmaker[Session], - missing: str, - status: int, - error_code: str, -) -> None: - """Exercise the admitted handler, real export reads and HTTP error formatter.""" - app_id, tenant_id, workflow_id = str(uuid4()), str(uuid4()), str(uuid4()) - if missing != "app": - sqlite_session.add( - App( - id=app_id, - tenant_id=tenant_id, - name="Exported workflow", - mode=AppMode.WORKFLOW, - enable_api=True, - enable_site=False, - ) - ) - if missing in {"version", "draft_version"}: - # A draft exists, but an explicitly requested version must not fall back to it. - sqlite_session.add( - Workflow( - id=workflow_id if missing == "draft_version" else str(uuid4()), - tenant_id=tenant_id, - app_id=app_id, - type=WorkflowType.WORKFLOW, - version=Workflow.VERSION_DRAFT, - graph='{"nodes": [], "edges": []}', - features="{}", - created_by="account-id", - ) - ) - sqlite_session.commit() - - app = Flask(__name__) - app.config["TESTING"] = True - app.extensions["application_services"] = ExportServices( - apps=ExportAppServices( - exports=AppExportService( - apps=AppExportRepository(session_factory=sqlite_session_factory), - serialize=AppDslService.serialize_export_data, - ) - ) - ) - api = ExternalApi(app, error_body_formatter=OpenApiErrorFormatter()) - - class AdmittedExport(Resource): - def get(self, app_id: str) -> tuple[AppDslExportResponse, int]: - return cast(_ExportEndpoint, AppDslExportApi.get).__handler__( - AppDslExportApi(), - AppRequestContext(tenant_id=tenant_id, app_id=app_id), - app_id, - query=AppDslExportQuery.model_validate(request.args.to_dict(flat=True)), - ) - - api.add_resource(AdmittedExport, "/apps//dsl") - response = app.test_client().get( - f"/apps/{app_id}/dsl", - query_string={"workflow_id": workflow_id} if missing in {"version", "draft_version"} else {}, - ) - - messages = { - "app": "app not found", - "draft": "Missing draft workflow configuration, please check.", - "version": f"Workflow version not found. Workflow ID: {workflow_id}.", - "draft_version": ( - f"Cannot use draft workflow version. Workflow ID: {workflow_id}. " - "Please use a published workflow version or leave workflow_id empty." - ), - } - assert response.status_code == status - assert response.get_json() == {"code": error_code, "message": messages[missing], "status": status} - - @pytest.mark.parametrize( ("api", "kwargs"), [ @@ -215,3 +137,433 @@ def test_import_hints_a_confirm_only_while_pending( ) assert (code, response.hints) == (status, hints) + + +_EXPORT_CONTEXT = AppRequestContext(tenant_id="tenant-1", app_id="app-1") + + +def _export(app: Flask, query: AppDslExportQuery) -> tuple[AppDslExportResponse, int]: + api = AppDslExportApi() + with app.test_request_context("/openapi/v1/apps/app-1/dsl"): + return cast(_ExportView, api.get).__handler__(api, _EXPORT_CONTEXT, "app-1", query=query) + + +def test_export_reads_draft_hash_before_building_dsl( + app: Flask, app_query_services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + """The hash must describe the draft as of before the export, not after: an edit that + commits in between would otherwise make a later import silently overwrite it.""" + sqlite_session.add( + App(id="app-1", tenant_id="tenant-1", name="a", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True) + ) + draft = Workflow( + tenant_id="tenant-1", + app_id="app-1", + type=WorkflowType.WORKFLOW, + version=Workflow.VERSION_DRAFT, + graph='{"nodes": [], "edges": []}', + features="{}", + created_by="account-1", + ) + sqlite_session.add(draft) + sqlite_session.commit() + call_order: list[str] = [] + + def read_token(tenant_id: str, app_id: str) -> str | None: + call_order.append("draft_hash") + return current_draft_token(tenant_id, app_id) + + def export_app(context: AppRequestContext, _options: AppExportOptions) -> str: + call_order.append("export") + assert context == _EXPORT_CONTEXT + return "app: {}" + + monkeypatch.setattr(app_dsl, "current_draft_token", read_token) + monkeypatch.setattr(app_query_services.apps.exports, "export_app", export_app) + + response, status = _export(app, AppDslExportQuery()) + + assert call_order == ["draft_hash", "export"] + assert response.draft_hash == draft_token(draft) + assert status == 200 + + +class _CheckView(Protocol): + __handler__: Callable[..., tuple[DslCheckResponse, int]] + + +_CONTEXT = RequestContext("request-1", None, "account-1", "workspace-1") +_START: dict[str, object] = {"id": "start", "data": {"type": "start", "title": "Start", "variables": []}} +_END: dict[str, object] = {"id": "end", "data": {"type": "end", "title": "End", "outputs": []}} +_ANSWER: dict[str, object] = {"id": "a", "data": {"type": "answer", "title": "A", "answer": "hi"}} + + +def _dsl(mode: str, *nodes: Mapping[str, object]) -> str: + return yaml.safe_dump( + {"app": {"mode": mode, "name": "x"}, "workflow": {"graph": {"nodes": list(nodes), "edges": []}}} + ) + + +class _CredentialValidator: + """The one `WorkflowService` call the check makes, refusing with `problem` when set.""" + + def __init__(self, problem: str | None) -> None: + self._problem = problem + + def validate_node_credentials(self, *_args: object, **_kwargs: object) -> None: + if self._problem: + raise ValueError(self._problem) + + +def _recorded_import( + monkeypatch: pytest.MonkeyPatch, services: ControllerTestServices, result: Import | None = None +) -> list[DraftGuardedImportParams]: + calls: list[DraftGuardedImportParams] = [] + + def import_app(_context: RequestContext, params: DraftGuardedImportParams) -> Import | None: + calls.append(params) + return result + + monkeypatch.setattr(services.apps.imports, "import_app", import_app) + return calls + + +@pytest.fixture +def credentials(monkeypatch: pytest.MonkeyPatch) -> Callable[[str | None], None]: + def set_problem(problem: str | None) -> None: + monkeypatch.setattr(graph_check, "WorkflowService", lambda: _CredentialValidator(problem)) + + set_problem(None) + return set_problem + + +def _check(app: Flask, body: DslCheckPayload) -> DslCheckResponse: + api = AppDslCheckApi() + with app.test_request_context("/openapi/v1/workspaces/workspace-1/apps/imports:check", method="POST"): + response, status = cast(_CheckView, api.post).__handler__(api, _CONTEXT, workspace_id="workspace-1", body=body) + assert status == 200 + return response + + +def test_import_refuses_a_graph_with_errors_and_hints_the_check( + app_query_services: ControllerTestServices, app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + import_calls = _recorded_import(monkeypatch, app_query_services) + + api = AppDslImportApi() + with app.test_request_context("/openapi/v1/workspaces/workspace-1/apps/imports", method="POST"): + with pytest.raises(DslInvalid) as exc_info: + cast(_EndpointView, api.post).__handler__( + api, + _CONTEXT, + workspace_id="workspace-1", + body=AppDslImportPayload(mode="yaml-content", yaml_content=_dsl("workflow", _START, _ANSWER)), + ) + + assert import_calls == [] + assert [(d.type, d.loc) for d in exc_info.value.details or []] == [ + (IssueCode.MODE_INCOMPATIBLE, ["nodes", "a", "data", "type"]) + ] + assert [h.op for h in exc_info.value.hints or []] == [op_of(AppDslCheckApi.post)] + + +@pytest.mark.parametrize( + "yaml_content", + [ + pytest.param(_dsl("chat"), id="not-a-graph-mode"), + pytest.param("app: [", id="unparseable"), + pytest.param(_dsl("future-mode", _ANSWER), id="unknown-mode"), + ], +) +def test_import_leaves_dsls_the_check_cannot_read_to_the_import( + app_query_services: ControllerTestServices, app: Flask, monkeypatch: pytest.MonkeyPatch, yaml_content: str +) -> None: + result = Import(id="import-1", status=ImportStatus.FAILED, error="from the import") + monkeypatch.setattr(app_query_services.apps.imports, "import_app", lambda *_args, **_kwargs: result) + + api = AppDslImportApi() + with app.test_request_context("/openapi/v1/workspaces/workspace-1/apps/imports", method="POST"): + response, status = cast(_EndpointView, api.post).__handler__( + api, + _CONTEXT, + workspace_id="workspace-1", + body=AppDslImportPayload(mode="yaml-content", yaml_content=yaml_content), + ) + + assert (status, response.error) == (400, "from the import") + + +@pytest.mark.usefixtures("app_query_services") +def test_check_reports_credential_problems_as_warnings(app: Flask, credentials: Callable[[str | None], None]) -> None: + credentials("no key") + + response = _check(app, DslCheckPayload(yaml_content=_dsl("workflow", _START, _END))) + + assert response.valid is True + assert {(row.code, row.severity, row.message) for row in response.issues} == { + (IssueCode.RESOURCE_UNAVAILABLE, IssueSeverity.WARNING, "no key") + } + + +@pytest.mark.usefixtures("app_query_services", "credentials") +def test_check_uses_the_overwritten_apps_mode(app: Flask, sqlite_session: Session) -> None: + sqlite_session.add( + App( + id="app-1", + tenant_id="workspace-1", + name="a", + mode=AppMode.ADVANCED_CHAT, + enable_site=True, + enable_api=True, + ) + ) + sqlite_session.commit() + + response = _check(app, DslCheckPayload(yaml_content=_dsl("workflow", _START, _END), app_id="app-1")) + + assert response.valid is False + assert [(row.code, row.node_id) for row in response.issues] == [(IssueCode.MODE_INCOMPATIBLE, "end")] + + +@pytest.mark.usefixtures("app_query_services", "credentials") +def test_check_answers_404_for_an_app_outside_the_workspace(app: Flask, sqlite_session: Session) -> None: + sqlite_session.add( + App(id="app-1", tenant_id="workspace-2", name="a", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True) + ) + sqlite_session.commit() + + with pytest.raises(NotFound): + _check(app, DslCheckPayload(yaml_content=_dsl("workflow", _START), app_id="app-1")) + + +@pytest.mark.parametrize("yaml_content", [_dsl("chat"), _dsl("future-mode"), "app: [", "- a list"]) +@pytest.mark.usefixtures("app_query_services", "credentials") +def test_check_refuses_dsls_it_cannot_read(app: Flask, yaml_content: str) -> None: + with pytest.raises(BadRequest): + _check(app, DslCheckPayload(yaml_content=yaml_content)) + + +@pytest.mark.parametrize( + ("workflow_id", "raised", "expected"), + [ + ("6f1c2a52-0d43-4f7e-9a3b-1c2d3e4f5a6b", WorkflowNotFoundError("gone"), VersionNotFound), + ("6f1c2a52-0d43-4f7e-9a3b-1c2d3e4f5a6b", IsDraftWorkflowError("draft"), VersionNotFound), + (None, WorkflowNotFoundError("no draft"), DraftNotFound), + (None, AppDiscoveryNotFoundError("app not found"), NotFoundError), + ], +) +def test_export_answers_a_missing_workflow_with_the_openapi_error( + app: Flask, + app_query_services: ControllerTestServices, + monkeypatch: pytest.MonkeyPatch, + workflow_id: str | None, + raised: Exception, + expected: type[Exception], +) -> None: + def export_app(*_args: object) -> str: + raise raised + + monkeypatch.setattr(app_query_services.apps.exports, "export_app", export_app) + + with pytest.raises(expected): + _export(app, AppDslExportQuery(workflow_id=workflow_id)) + + +def test_import_hands_the_draft_hash_to_the_save( + app_query_services: ControllerTestServices, app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(app_query_services.apps.dsl_imports, "prepare", lambda *_args, **_kwargs: "prepared yaml") + completed = Import(id="import-1", status=ImportStatus.COMPLETED, app_id="app-1") + import_calls = _recorded_import(monkeypatch, app_query_services, completed) + + api = AppDslImportApi() + with app.test_request_context("/openapi/v1/workspaces/workspace-1/apps/imports", method="POST"): + cast(_EndpointView, api.post).__handler__( + api, + _CONTEXT, + workspace_id="workspace-1", + body=AppDslImportPayload(mode="yaml-content", yaml_content="x", app_id="app-1", draft_hash="exported"), + ) + + [params] = import_calls + assert (params.draft_hash, params.yaml_content, params.app_id) == ("exported", "prepared yaml", "app-1") + + +@pytest.mark.parametrize( + ("api", "kwargs"), + [ + ( + AppDslImportApi(), + { + "workspace_id": "workspace-1", + "body": AppDslImportPayload( + mode="yaml-content", yaml_content="x", app_id="app-1", draft_hash="exported" + ), + }, + ), + (AppDslImportConfirmApi(), {"workspace_id": "workspace-1", "import_id": "import-1"}), + ], + ids=["import", "confirm"], +) +def test_a_draft_changed_during_the_save_answers_draft_changed( + app_query_services: ControllerTestServices, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + api: AppDslImportApi | AppDslImportConfirmApi, + kwargs: dict[str, object], +) -> None: + def changed(*_args: object, **_kwargs: object) -> None: + raise DraftChangedError() + + monkeypatch.setattr(app_query_services.apps.dsl_imports, "prepare", lambda *_args, **_kwargs: "prepared yaml") + monkeypatch.setattr(app_query_services.apps.imports, "import_app", changed) + monkeypatch.setattr(app_query_services.apps.imports, "confirm_import", changed) + + with app.test_request_context("/openapi/v1/workspaces/workspace-1/apps/imports", method="POST"): + with pytest.raises(DraftChanged) as exc_info: + cast(_EndpointView, api.post).__handler__(api, _CONTEXT, **kwargs) + + assert exc_info.value.code == 409 + + +def _raw_dsl(graph: object) -> str: + return yaml.safe_dump({"app": {"mode": "workflow", "name": "x"}, "workflow": {"graph": graph}}) + + +_MALFORMED_GRAPHS = [ + pytest.param({"nodes": [{"id": "a", "data": None}], "edges": []}, id="null-data"), + pytest.param({"nodes": [{"id": "a", "data": {"type": "start"}}], "edges": 5}, id="scalar-edges"), + pytest.param({"nodes": [5, "x"], "edges": []}, id="scalar-nodes"), + pytest.param({"nodes": [{"data": {"type": "start"}}], "edges": []}, id="no-id"), + pytest.param({"nodes": "start", "edges": []}, id="scalar-node-list"), +] + + +@pytest.mark.parametrize("graph", _MALFORMED_GRAPHS) +@pytest.mark.usefixtures("app_query_services", "credentials") +def test_check_reports_malformed_graphs_as_issues(app: Flask, graph: object) -> None: + response = _check(app, DslCheckPayload(yaml_content=_raw_dsl(graph))) + + assert response.valid is False + assert {row.code for row in response.issues} == {IssueCode.GRAPH_INVALID} + + +@pytest.mark.parametrize("graph", _MALFORMED_GRAPHS) +@pytest.mark.usefixtures("credentials") +def test_import_refuses_malformed_graphs( + app_query_services: ControllerTestServices, app: Flask, monkeypatch: pytest.MonkeyPatch, graph: object +) -> None: + import_calls = _recorded_import(monkeypatch, app_query_services) + + api = AppDslImportApi() + with app.test_request_context("/openapi/v1/workspaces/workspace-1/apps/imports", method="POST"): + with pytest.raises(DslInvalid) as exc_info: + cast(_EndpointView, api.post).__handler__( + api, + _CONTEXT, + workspace_id="workspace-1", + body=AppDslImportPayload(mode="yaml-content", yaml_content=_raw_dsl(graph)), + ) + + assert import_calls == [] + assert {d.type for d in exc_info.value.details or []} == {IssueCode.GRAPH_INVALID} + + +@pytest.mark.parametrize("environment_variables", [5, "KEY", [5], [None]]) +@pytest.mark.usefixtures("app_query_services", "credentials") +def test_check_refuses_malformed_environment_variables(app: Flask, environment_variables: object) -> None: + yaml_content = yaml.safe_dump( + { + "app": {"mode": "workflow", "name": "x"}, + "workflow": {"graph": {"nodes": [], "edges": []}, "environment_variables": environment_variables}, + } + ) + + with pytest.raises(BadRequest, match="environment_variables"): + _check(app, DslCheckPayload(yaml_content=yaml_content)) + + +@dataclass(frozen=True) +class ExportAppServices: + exports: AppExportService + + +@dataclass(frozen=True) +class ExportServices: + apps: ExportAppServices + + +@pytest.mark.parametrize( + ("missing", "status", "error_code"), + [ + ("app", 404, "not_found"), + ("draft", 404, "draft_not_found"), + ("version", 404, "version_not_found"), + ("draft_version", 404, "version_not_found"), + ], +) +def test_export_errors_have_canonical_http_responses( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + missing: str, + status: int, + error_code: str, +) -> None: + """Exercise the admitted handler, real export reads and HTTP error formatter.""" + app_id, tenant_id, workflow_id = str(uuid4()), str(uuid4()), str(uuid4()) + if missing != "app": + sqlite_session.add( + App( + id=app_id, + tenant_id=tenant_id, + name="Exported workflow", + mode=AppMode.WORKFLOW, + enable_api=True, + enable_site=False, + ) + ) + if missing in {"version", "draft_version"}: + # A draft exists, but an explicitly requested version must not fall back to it. + sqlite_session.add( + Workflow( + id=workflow_id if missing == "draft_version" else str(uuid4()), + tenant_id=tenant_id, + app_id=app_id, + type=WorkflowType.WORKFLOW, + version=Workflow.VERSION_DRAFT, + graph='{"nodes": [], "edges": []}', + features="{}", + created_by="account-id", + ) + ) + sqlite_session.commit() + + app = Flask(__name__) + app.config["TESTING"] = True + app.extensions["application_services"] = ExportServices( + apps=ExportAppServices( + exports=AppExportService( + apps=AppExportRepository(session_factory=sqlite_session_factory), + serialize=AppDslService.serialize_export_data, + ) + ) + ) + api = ExternalApi(app, error_body_formatter=OpenApiErrorFormatter()) + + class AdmittedExport(Resource): + def get(self, app_id: str) -> tuple[AppDslExportResponse, int]: + return cast(_ExportView, AppDslExportApi.get).__handler__( + AppDslExportApi(), + AppRequestContext(tenant_id=tenant_id, app_id=app_id), + app_id, + query=AppDslExportQuery.model_validate(request.args.to_dict(flat=True)), + ) + + api.add_resource(AdmittedExport, "/apps//dsl") + response = app.test_client().get( + f"/apps/{app_id}/dsl", + query_string={"workflow_id": workflow_id} if missing in {"version", "draft_version"} else {}, + ) + + assert response.status_code == status + assert (response.get_json()["code"], response.get_json()["status"]) == (error_code, status) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_release.py b/api/tests/unit_tests/controllers/openapi/test_app_release.py new file mode 100644 index 00000000000..2aea06fd7aa --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_release.py @@ -0,0 +1,50 @@ +from datetime import datetime, timedelta + +import pytest + +from controllers.openapi.app_release import passed_on_draft +from graphon.enums import WorkflowExecutionStatus +from models.workflow import Workflow, WorkflowRun, WorkflowType + +_GRAPH = '{"nodes": [{"id": "start", "data": {"type": "start"}}], "edges": []}' +_OTHER_GRAPH = '{"nodes": [{"id": "llm", "data": {"type": "llm"}}], "edges": []}' +_RUN_STARTED = datetime(2026, 10, 15, 12, 0, 0) + + +def _draft(*, saved_at: datetime, graph: str = _GRAPH) -> Workflow: + draft = Workflow( + tenant_id="workspace-1", + app_id="app-1", + type=WorkflowType.WORKFLOW, + version=Workflow.VERSION_DRAFT, + graph=graph, + features="{}", + created_by="account-1", + ) + draft.updated_at = saved_at + return draft + + +def _run(*, status: WorkflowExecutionStatus = WorkflowExecutionStatus.SUCCEEDED, graph: str = _GRAPH) -> WorkflowRun: + return WorkflowRun(status=status, graph=graph, created_at=_RUN_STARTED) + + +def test_a_passing_run_on_the_unchanged_draft_counts() -> None: + assert passed_on_draft(_run(), _draft(saved_at=_RUN_STARTED - timedelta(minutes=1))) is True + + +def test_a_draft_saved_after_the_run_started_needs_a_new_test() -> None: + """Environment variables, conversation variables and features live outside the run's graph.""" + assert passed_on_draft(_run(), _draft(saved_at=_RUN_STARTED + timedelta(seconds=1))) is False + + +@pytest.mark.parametrize( + "run", + [ + pytest.param(None, id="no-run"), + pytest.param(_run(status=WorkflowExecutionStatus.FAILED), id="failed"), + pytest.param(_run(graph=_OTHER_GRAPH), id="other-graph"), + ], +) +def test_no_passing_run_on_this_graph_does_not_count(run: WorkflowRun | None) -> None: + assert passed_on_draft(run, _draft(saved_at=_RUN_STARTED - timedelta(minutes=1))) is False diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py index 88940909543..b22e03c0a73 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py @@ -13,8 +13,9 @@ import pytest from flask import Flask from pydantic import BaseModel, ValidationError from werkzeug.datastructures import FileStorage -from werkzeug.exceptions import UnprocessableEntity +from controllers.openapi import app_run +from controllers.openapi._errors import AppModeMismatch from controllers.openapi._models import ( AdvancedChatRunPayload, ChatRunPayload, @@ -23,6 +24,7 @@ from controllers.openapi._models import ( WorkflowRunPayload, ) from controllers.openapi.app_run import AdvancedChatRunApi, AppRunTaskStopApi, ChatRunApi, CompletionRunApi +from core.app.entities.app_invoke_entities import InvokeFrom from graphon.file import FileType from models import Account from models.enums import CreatorUserRole @@ -158,7 +160,7 @@ def test_per_mode_route_refuses_an_app_of_another_mode(app: Flask, monkeypatch: api = ChatRunApi() body = ChatRunPayload(inputs={}, query="hi") with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/chat:run", method="POST"): - with pytest.raises(UnprocessableEntity, match="app_mode_mismatch"): + with pytest.raises(AppModeMismatch): api.post.__handler__(api, _ctx(AppMode.WORKFLOW), app_id=_TEST_APP_ID, body=body) generate_mock.assert_not_called() @@ -227,3 +229,13 @@ def test_chat_route_passes_a_message_end_without_conversation_through(app: Flask with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/chat:run", method="POST"): response = api.post.__handler__(api, _ctx(AppMode.CHAT), app_id=_TEST_APP_ID, body=body) assert list(response.response) == [end] + + +def test_draft_runs_do_not_hint_the_form_submit_op() -> None: + """A draft run's human-input form is a debugger form; the submit op only takes web-app + forms, so hinting it would send the agent straight into a 403.""" + draft_routes = [route for route in app_run._RUN_ROUTES if route.invoke_from == InvokeFrom.DEBUGGER] + assert draft_routes + for route in draft_routes: + assert app_run._form_layer not in route.hints + assert "cannot be resumed over openapi" in route.summary diff --git a/api/tests/unit_tests/controllers/openapi/test_app_settings.py b/api/tests/unit_tests/controllers/openapi/test_app_settings.py new file mode 100644 index 00000000000..38e091356da --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_settings.py @@ -0,0 +1,164 @@ +import uuid +from collections.abc import Callable +from dataclasses import replace + +import pytest +from sqlalchemy.orm import Session, sessionmaker + +from constants.oauth_bearer import TokenType +from controllers.openapi import _app_settings +from controllers.openapi._app_settings import WebAppPath +from controllers.openapi._errors import WebAppAccessModeConsoleOnly, WebAppAccessUnavailable, WebAppPublicAccessDisabled +from controllers.openapi._models import WebAppAccessPayload +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.subjects import AccountSubject +from enums import WebAppAccessMode +from extensions.ext_application_services import ApplicationServices +from libs.oauth_bearer import AuthContext +from models import AppMode +from repositories.webapp_access_query_repository import WebAppAccessQueryRepository +from services.enterprise.enterprise_service import EnterpriseService, WebAppSettings +from services.errors.enterprise import EnterpriseServiceError +from services.webapp_access_adapters import EnterpriseWebAppAccessPolicyGateway +from services.webapp_access_query_service import WebAppAccessQueryService +from tests.unit_tests.model_factories import make_app + + +def _context(session: Session) -> Context: + subject = AccountSubject( + AuthContext( + subject_email="caller@example.com", + subject_issuer="dify:account", + account_id=uuid.uuid4(), + client_id="difyctl", + token_id=uuid.uuid4(), + token_type=TokenType.OAUTH_ACCOUNT, + expires_at=None, + ) + ) + ctx = Context(subject, session, {"app_id": "app-1"}) + ctx._app = make_app(app_id="app-1") + return ctx + + +def _enterprise_cannot_read_access_modes(monkeypatch: pytest.MonkeyPatch) -> None: + def get_app_access_mode_by_id(_app_id: str) -> WebAppSettings: + raise EnterpriseServiceError("No data found.") + + monkeypatch.setattr(EnterpriseService.WebAppAuth, "get_app_access_mode_by_id", get_app_access_mode_by_id) + + +def _record_access_mode_writes(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str]]: + writes: list[tuple[str, str]] = [] + + def update_app_access_mode(app_id: str, access_mode: str) -> None: + writes.append((app_id, access_mode)) + + monkeypatch.setattr(EnterpriseService.WebAppAuth, "update_app_access_mode", update_app_access_mode) + return writes + + +def test_webapp_access_read_failure_is_unavailable( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + account_application_services: ApplicationServices, +) -> None: + _enterprise_cannot_read_access_modes(monkeypatch) + webapp_access = WebAppAccessQueryService( + access=WebAppAccessQueryRepository(session_factory=sqlite_session_factory), + policy=EnterpriseWebAppAccessPolicyGateway(webapp_auth=EnterpriseService.WebAppAuth), + webapp_auth_enabled=True, + get_access_modes=lambda **_: {}, + get_user_permissions=lambda **_: {}, + ) + services = replace(account_application_services, webapp_access=webapp_access) + monkeypatch.setattr(_app_settings, "application_services", lambda: services) + + with pytest.raises(WebAppAccessUnavailable): + _app_settings.webapp_access(_context(sqlite_session)) + + +def test_set_webapp_access_returns_the_written_mode_without_reading_back( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + """A failing read after a saved write must not report the write as failed.""" + _enterprise_cannot_read_access_modes(monkeypatch) + writes = _record_access_mode_writes(monkeypatch) + + result = _app_settings.update_webapp_access( + _context(sqlite_session), WebAppAccessPayload(access_mode=WebAppAccessMode.PRIVATE_ALL) + ) + + assert result.access_mode == WebAppAccessMode.PRIVATE_ALL + assert writes == [("app-1", WebAppAccessMode.PRIVATE_ALL)] + + +def test_set_webapp_access_public_is_refused_when_the_server_disables_public_access( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, config_overrides: Callable[..., None] +) -> None: + config_overrides(WEBAPP_PUBLIC_ACCESS_ENABLED=False) + writes = _record_access_mode_writes(monkeypatch) + + with pytest.raises(WebAppPublicAccessDisabled): + _app_settings.update_webapp_access( + _context(sqlite_session), WebAppAccessPayload(access_mode=WebAppAccessMode.PUBLIC) + ) + + assert writes == [] + + +@pytest.mark.parametrize("mode", [WebAppAccessMode.PRIVATE_ALL, WebAppAccessMode.SSO_VERIFIED]) +def test_set_webapp_access_non_public_modes_are_unaffected_by_the_public_switch( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + config_overrides: Callable[..., None], + mode: WebAppAccessMode, +) -> None: + config_overrides(WEBAPP_PUBLIC_ACCESS_ENABLED=False) + writes = _record_access_mode_writes(monkeypatch) + + result = _app_settings.update_webapp_access(_context(sqlite_session), WebAppAccessPayload(access_mode=mode)) + + assert result.access_mode == mode + assert writes == [("app-1", mode)] + + +def test_set_webapp_access_public_is_written_when_the_server_allows_it( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, config_overrides: Callable[..., None] +) -> None: + config_overrides(WEBAPP_PUBLIC_ACCESS_ENABLED=True) + writes = _record_access_mode_writes(monkeypatch) + + result = _app_settings.update_webapp_access( + _context(sqlite_session), WebAppAccessPayload(access_mode=WebAppAccessMode.PUBLIC) + ) + + assert result.access_mode == WebAppAccessMode.PUBLIC + assert writes == [("app-1", WebAppAccessMode.PUBLIC)] + + +def test_set_webapp_access_private_still_answers_console_only_when_public_is_disabled( + sqlite_session: Session, config_overrides: Callable[..., None] +) -> None: + config_overrides(WEBAPP_PUBLIC_ACCESS_ENABLED=False) + + with pytest.raises(WebAppAccessModeConsoleOnly): + _app_settings.update_webapp_access( + _context(sqlite_session), WebAppAccessPayload(access_mode=WebAppAccessMode.PRIVATE) + ) + + +@pytest.mark.parametrize( + ("mode", "path"), + [ + (AppMode.WORKFLOW, WebAppPath.WORKFLOW), + (AppMode.ADVANCED_CHAT, WebAppPath.CHAT), + (AppMode.CHAT, WebAppPath.CHAT), + (AppMode.AGENT_CHAT, WebAppPath.CHAT), + (AppMode.COMPLETION, WebAppPath.COMPLETION), + (AppMode.AGENT, WebAppPath.AGENT), + ], +) +def test_web_app_path_follows_the_app_mode(mode: AppMode, path: WebAppPath) -> None: + assert WebAppPath.of(mode) is path diff --git a/api/tests/unit_tests/controllers/openapi/test_app_workflow.py b/api/tests/unit_tests/controllers/openapi/test_app_workflow.py new file mode 100644 index 00000000000..0daeb7caf7a --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_workflow.py @@ -0,0 +1,554 @@ +import json +import uuid +from collections.abc import Callable, Sequence +from datetime import datetime +from io import BytesIO +from typing import Protocol, cast + +import pytest +from flask import Flask +from pydantic import ValidationError +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker +from werkzeug.datastructures import FileStorage +from werkzeug.exceptions import BadRequest + +from constants.oauth_bearer import TokenType +from controllers.openapi import app_workflow +from controllers.openapi._errors import ( + DraftNotFound, + EnvVariableNotFound, + RunNotFound, + SecretMaskNotSecret, + SecretMaskUnknownId, + VersionNotFound, +) +from controllers.openapi._hints import cursor_after, next_page_hint +from controllers.openapi._models import ( + AdvancedChatNodeRunPayload, + EnvVariableSetPayload, + NodeRunPayload, + PublishPayload, + PublishResponse, + RestoreResponse, + RunListQuery, + VersionListQuery, + VersionListResponse, +) +from controllers.openapi._upload import UploadParts, file_fields +from controllers.openapi.app_workflow import ( + AppEnvItemApi, + AppPublishApi, + AppRunNodeListApi, + AppVersionListApi, + AppVersionRestoreApi, + env_variable_rows, + run_draft_node, +) +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.subjects import AccountSubject +from core.helper import encrypter +from extensions.ext_application_services import ApplicationServices +from graphon.variables import SecretVariable, StringVariable, VariableBase +from libs.oauth_bearer import AuthContext +from models.account import Account, Tenant +from models.enums import AppStatus +from models.model import App, AppMode, IconType +from models.workflow import Workflow, WorkflowType +from services.workflow.graph_diff import draft_token +from services.workflow_service import WorkflowService + +WORKSPACE_ID = "00000000-0000-4000-8000-000000000001" +APP_ID = "00000000-0000-4000-8000-000000000002" +ACCOUNT_ID = "00000000-0000-4000-8000-000000000003" +VERSION_ID = "00000000-0000-4000-8000-000000000004" + + +class _EndpointView(Protocol): + """Structural stand-in for a `view` carrying the attributes `@endpoint` attaches.""" + + __handler__: Callable[..., object] + + +@pytest.fixture(autouse=True) +def _identity_secret_encryption(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep secret variables readable without the workspace key provider.""" + monkeypatch.setattr(encrypter, "encrypt_token", lambda **kwargs: kwargs["token"]) + monkeypatch.setattr(encrypter, "decrypt_token", lambda **kwargs: kwargs["token"]) + + +def _app_context(session: Session, **view_args: str) -> Context: + auth = AuthContext( + token_type=TokenType.OAUTH_ACCOUNT, + subject_email="tester@example.com", + subject_issuer="dify:account", + account_id=uuid.UUID(ACCOUNT_ID), + client_id="difyctl", + token_id=uuid.uuid4(), + expires_at=None, + ) + ctx = Context(AccountSubject(auth), session, {"app_id": APP_ID, **view_args}) + ctx._workspace = Tenant(name="w") + ctx._workspace.id = WORKSPACE_ID + ctx._app = App( + id=APP_ID, + tenant_id=WORKSPACE_ID, + name="a", + description="", + mode=AppMode.WORKFLOW, + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#FFFFFF", + status=AppStatus.NORMAL, + enable_site=True, + enable_api=True, + ) + session.add(ctx._app) + session.commit() + account = Account(name="tester", email="tester@example.com") + account.id = ACCOUNT_ID + ctx._caller = account + return ctx + + +def _workflow_service(monkeypatch: pytest.MonkeyPatch, session_factory: sessionmaker[Session]) -> WorkflowService: + """`WorkflowService()` builds its sessions from db.engine, which the bare Flask app these tests run + under does not have; hand the handlers the real service over the test database instead.""" + service = WorkflowService(session_factory) + monkeypatch.setattr(app_workflow, "WorkflowService", lambda: service) + return service + + +def _workflow( + *, version: str = Workflow.VERSION_DRAFT, graph: str = "{}", environment_variables: Sequence[VariableBase] = () +) -> Workflow: + return Workflow.new( + tenant_id=WORKSPACE_ID, + app_id=APP_ID, + type=WorkflowType.WORKFLOW, + version=version, + graph=graph, + features="{}", + created_by=ACCOUNT_ID, + environment_variables=environment_variables, + conversation_variables=[], + rag_pipeline_variables=[], + ) + + +def _add_draft(session: Session, *, graph: str = "{}", environment_variables: Sequence[VariableBase] = ()) -> Workflow: + draft = _workflow(graph=graph, environment_variables=environment_variables) + session.add(draft) + session.commit() + return draft + + +def _env(draft: Workflow) -> list[tuple[str, object, type]]: + return [(variable.id, variable.value, type(variable)) for variable in draft.environment_variables] + + +def test_env_variable_view_masks_only_secrets_that_have_a_value() -> None: + """An empty secret reads back empty, not masked: masking it would claim a value that is not there.""" + secret = SecretVariable(id="s", name="KEY", value="plain", selector=["env", "KEY"]) + empty_secret = SecretVariable(id="e", name="BLANK", value="", selector=["env", "BLANK"]) + plain = StringVariable(id="p", name="URL", value="https://x", selector=["env", "URL"]) + rows = env_variable_rows([secret, empty_secret, plain]) + assert [row.value for row in rows] == [encrypter.full_mask_token(), "", "https://x"] + + +def test_run_list_hints_the_next_cursor() -> None: + [hint] = next_page_hint( + op="get.run", + path_args={"app_id": "app-1"}, + query=RunListQuery(limit=1), + page=cursor_after(last_id="run-1", has_more=True), + ) + assert hint.input == {"app_id": "app-1", "limit": 1, "triggered_from": "debugging", "last_id": "run-1"} + + +def test_set_env_rejects_mask_for_unknown_id( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + """Sending the mask for an id that is not a stored secret must not encrypt the + literal mask into the draft (the bug: it silently succeeded, storing `[__HIDDEN__]`).""" + _workflow_service(monkeypatch, sqlite_session_factory) + ctx = _app_context(sqlite_session, env_id="unknown-id") + draft = _add_draft(sqlite_session) + + body = EnvVariableSetPayload(name="KEY", value_type="secret", value=encrypter.full_mask_token()) + api = AppEnvItemApi() + with app.test_request_context("/openapi/v1/apps/app-1/env/unknown-id", method="PUT"): + with pytest.raises(SecretMaskUnknownId): + cast(_EndpointView, api.put).__handler__(api, ctx, "app-1", "unknown-id", body=body) + + assert _env(draft) == [] + + +def test_set_env_accepts_mask_for_a_stored_secret( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + """The same mask for an id that *is* a stored secret keeps the existing flow working.""" + stored = SecretVariable(id="s-1", name="KEY", value="plain", selector=["env", "KEY"]) + _workflow_service(monkeypatch, sqlite_session_factory) + ctx = _app_context(sqlite_session, env_id="s-1") + draft = _add_draft(sqlite_session, environment_variables=[stored]) + + body = EnvVariableSetPayload(name="KEY", value_type="secret", value=encrypter.full_mask_token()) + api = AppEnvItemApi() + with app.test_request_context("/openapi/v1/apps/app-1/env/s-1", method="PUT"): + cast(_EndpointView, api.put).__handler__(api, ctx, "app-1", "s-1", body=body) + + assert _env(draft) == [("s-1", "plain", SecretVariable)] + assert draft.updated_by == ACCOUNT_ID + + +def test_set_env_rejects_the_mask_as_a_plain_value( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + """Re-typing a stored secret as a string while sending the mask would store the mask text + and destroy the secret.""" + stored = SecretVariable(id="s-1", name="KEY", value="plain", selector=["env", "KEY"]) + _workflow_service(monkeypatch, sqlite_session_factory) + ctx = _app_context(sqlite_session, env_id="s-1") + draft = _add_draft(sqlite_session, environment_variables=[stored]) + + body = EnvVariableSetPayload(name="KEY", value_type="string", value=encrypter.full_mask_token()) + api = AppEnvItemApi() + with app.test_request_context("/openapi/v1/apps/app-1/env/s-1", method="PUT"): + with pytest.raises(SecretMaskNotSecret): + cast(_EndpointView, api.put).__handler__(api, ctx, "app-1", "s-1", body=body) + + assert _env(draft) == [("s-1", "plain", SecretVariable)] + + +def test_set_env_without_a_draft_is_not_found( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _workflow_service(monkeypatch, sqlite_session_factory) + ctx = _app_context(sqlite_session, env_id="e-1") + + body = EnvVariableSetPayload(name="KEY", value_type="string", value="v") + api = AppEnvItemApi() + with app.test_request_context("/openapi/v1/apps/app-1/env/e-1", method="PUT"): + with pytest.raises(DraftNotFound): + cast(_EndpointView, api.put).__handler__(api, ctx, "app-1", "e-1", body=body) + + assert sqlite_session.scalars(select(Workflow)).all() == [] + + +def test_delete_env_without_a_draft_is_not_found( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _workflow_service(monkeypatch, sqlite_session_factory) + ctx = _app_context(sqlite_session, env_id="e-1") + + api = AppEnvItemApi() + with app.test_request_context("/openapi/v1/apps/app-1/env/e-1", method="DELETE"): + with pytest.raises(DraftNotFound): + cast(_EndpointView, api.delete).__handler__(api, ctx, "app-1", "e-1") + + assert sqlite_session.scalars(select(Workflow)).all() == [] + + +def test_delete_env_with_an_unknown_id_is_not_found( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + """An unknown id must not report success while deleting nothing.""" + stored = StringVariable(id="e-1", name="KEY", value="v", selector=["env", "KEY"]) + _workflow_service(monkeypatch, sqlite_session_factory) + ctx = _app_context(sqlite_session, env_id="typo") + draft = _add_draft(sqlite_session, environment_variables=[stored]) + + api = AppEnvItemApi() + with app.test_request_context("/openapi/v1/apps/app-1/env/typo", method="DELETE"): + with pytest.raises(EnvVariableNotFound): + cast(_EndpointView, api.delete).__handler__(api, ctx, "app-1", "typo") + + assert _env(draft) == [("e-1", "v", StringVariable)] + + +def test_delete_env_removes_a_stored_variable( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + stored = StringVariable(id="e-1", name="KEY", value="v", selector=["env", "KEY"]) + kept = StringVariable(id="e-2", name="URL", value="https://x", selector=["env", "URL"]) + _workflow_service(monkeypatch, sqlite_session_factory) + ctx = _app_context(sqlite_session, env_id="e-1") + draft = _add_draft(sqlite_session, environment_variables=[stored, kept]) + + api = AppEnvItemApi() + with app.test_request_context("/openapi/v1/apps/app-1/env/e-1", method="DELETE"): + cast(_EndpointView, api.delete).__handler__(api, ctx, "app-1", "e-1") + + assert _env(draft) == [("e-2", "https://x", StringVariable)] + + +def test_set_env_payload_rejects_a_value_type_the_console_does_not_allow() -> None: + with pytest.raises(ValidationError): + EnvVariableSetPayload(name="KEY", value_type="boolean", value=True) + + +def test_restore_rejects_a_garbage_version_id(app: Flask, sqlite_session: Session) -> None: + api = AppVersionRestoreApi() + ctx = _app_context(sqlite_session, version_id="not-a-uuid") + with app.test_request_context("/openapi/v1/apps/app-1/versions/not-a-uuid:restore", method="POST"): + with pytest.raises(VersionNotFound): + cast(_EndpointView, api.post).__handler__(api, ctx, "app-1", "not-a-uuid") + + +def test_version_list_excludes_the_draft( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _workflow_service(monkeypatch, sqlite_session_factory) + ctx = _app_context(sqlite_session) + _add_draft(sqlite_session) + published = _workflow(version=Workflow.version_from_datetime(datetime(2026, 10, 1, 12, 0, 0))) + sqlite_session.add(published) + sqlite_session.commit() + ctx.app.workflow_id = published.id + + api = AppVersionListApi() + with app.test_request_context("/openapi/v1/apps/app-1/versions"): + response = cast(_EndpointView, api.get).__handler__(api, ctx, "app-1", query=VersionListQuery()) + + assert [row.id for row in cast(VersionListResponse, response).data] == [published.id] + + +def test_restore_returns_the_hash_an_import_checks( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + service = _workflow_service(monkeypatch, sqlite_session_factory) + restored = _workflow(graph=_branch_skipping_graph()) + monkeypatch.setattr(service, "restore_published_workflow_to_draft", lambda **_kwargs: restored) + version_id = "11111111-1111-4111-8111-111111111111" + ctx = _app_context(sqlite_session, version_id=version_id) + + api = AppVersionRestoreApi() + with app.test_request_context(f"/openapi/v1/apps/app-1/versions/{version_id}:restore", method="POST"): + response = cast(_EndpointView, api.post).__handler__(api, ctx, "app-1", version_id) + + assert cast(RestoreResponse, response).draft_hash == draft_token(restored) + + +def test_run_node_list_is_not_found_for_a_run_of_another_app( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + account_application_services: ApplicationServices, +) -> None: + """Match describe: an unknown run is a 404, not an empty step list.""" + monkeypatch.setattr(app_workflow, "application_services", lambda: account_application_services) + step_reads: list[object] = [] + monkeypatch.setattr( + account_application_services.workflow_runs, + "get_workflow_run_node_executions", + lambda *args, **kwargs: step_reads.append((args, kwargs)), + ) + run_id = "11111111-1111-4111-8111-111111111111" + ctx = _app_context(sqlite_session, run_id=run_id) + + api = AppRunNodeListApi() + with app.test_request_context(f"/openapi/v1/apps/app-1/runs/{run_id}/nodes"): + with pytest.raises(RunNotFound): + cast(_EndpointView, api.get).__handler__(api, ctx, "app-1", run_id) + + assert step_reads == [] + + +@pytest.mark.parametrize("payload_cls", [NodeRunPayload, AdvancedChatNodeRunPayload]) +def test_node_run_payload_accepts_files_and_forbids_attachments(payload_cls: type[NodeRunPayload]) -> None: + assert payload_cls.model_validate({"inputs": {}, "files": None}).files is None + with pytest.raises(ValidationError): + payload_cls.model_validate({"attachments": []}) + + +def _code_node_graph(node_id: str) -> str: + return json.dumps({"nodes": [{"id": node_id, "data": {"type": "code"}}], "edges": []}) + + +def _upload_part() -> FileStorage: + return FileStorage(stream=BytesIO(b"doc"), filename="doc.txt", content_type="text/plain") + + +def test_run_draft_node_merges_local_files_into_the_inputs( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session] +) -> None: + service = _workflow_service(monkeypatch, sqlite_session_factory) + ctx = _app_context(sqlite_session) + _add_draft(sqlite_session, graph=_code_node_graph("n1")) + merges: list[tuple[object, ...]] = [] + + def merge_files(inputs: object, files: object, caller: object) -> dict[str, object]: + merges.append((inputs, files, caller)) + return {"x": 1, "doc": {"id": "file-1"}} + + node_runs: list[dict[str, object]] = [] + monkeypatch.setattr(app_workflow, "merge_files", merge_files) + monkeypatch.setattr(service, "run_draft_workflow_node", lambda **kwargs: node_runs.append(kwargs)) + monkeypatch.setattr(app_workflow, "node_execution_response_source", lambda *_args, **_kwargs: {"id": "e-1"}) + parts: UploadParts = {"doc": _upload_part()} + + run_draft_node(ctx, "n1", inputs={"x": 1}, query="", files=parts) + + assert merges == [({"x": 1}, parts, ctx.caller)] + assert [run["user_inputs"] for run in node_runs] == [{"x": 1, "doc": {"id": "file-1"}}] + + +def test_node_run_payloads_take_files_as_multipart_parts() -> None: + assert file_fields(NodeRunPayload) == {"files"} + assert file_fields(AdvancedChatNodeRunPayload) == {"files"} + + +@pytest.mark.parametrize(("with_files", "ends_transaction"), [(True, True), (False, False)]) +def test_run_draft_node_ends_the_read_transaction_before_uploading( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + with_files: bool, + ends_transaction: bool, +) -> None: + service = _workflow_service(monkeypatch, sqlite_session_factory) + ctx = _app_context(sqlite_session) + _add_draft(sqlite_session, graph=_code_node_graph("n1")) + names: list[str] = [] + monkeypatch.setattr(app_workflow, "end_read_transaction", lambda _session: names.append("end")) + + def merge_files(inputs: dict[str, object], *_args: object) -> dict[str, object]: + names.append("merge") + return inputs + + monkeypatch.setattr(app_workflow, "merge_files", merge_files) + monkeypatch.setattr(service, "run_draft_workflow_node", lambda **_kwargs: None) + monkeypatch.setattr(app_workflow, "node_execution_response_source", lambda *_args, **_kwargs: {"id": "e-1"}) + files: UploadParts | None = {"doc": _upload_part()} if with_files else None + + run_draft_node(ctx, "n1", inputs={}, query="", files=files) + + assert names == (["end", "merge"] if ends_transaction else ["merge"]) + + +def _branch_skipping_graph() -> str: + return json.dumps( + { + "nodes": [ + {"id": "start", "data": {"type": "start"}}, + {"id": "branch", "data": {"type": "if-else"}}, + {"id": "producer", "data": {"type": "code", "title": "Producer"}}, + {"id": "consumer", "data": {"type": "answer", "title": "Consumer", "answer": "{{#producer.text#}}"}}, + ], + "edges": [ + {"source": "start", "target": "branch"}, + {"source": "branch", "target": "producer", "sourceHandle": "true"}, + {"source": "branch", "target": "consumer", "sourceHandle": "false"}, + ], + } + ) + + +def _published_version(graph: str) -> Workflow: + version = _workflow(version=Workflow.version_from_datetime(datetime(2026, 10, 1, 12, 0, 0)), graph=graph) + version.id = VERSION_ID + version.created_at = datetime(2026, 10, 1, 12, 0, 0) + return version + + +@pytest.mark.parametrize("graph", ["{}", "{", ""], ids=["clean", "invalid-json", "empty"]) +def test_publish_points_the_app_at_the_new_version( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + graph: str, +) -> None: + service = _workflow_service(monkeypatch, sqlite_session_factory) + publishes: list[dict[str, object]] = [] + + def publish_workflow(**kwargs: object) -> Workflow: + publishes.append(kwargs) + return _published_version(graph) + + monkeypatch.setattr(service, "publish_workflow", publish_workflow) + ctx = _app_context(sqlite_session) + + api = AppPublishApi() + with app.test_request_context("/openapi/v1/apps/app-1:publish", method="POST"): + response = cast(_EndpointView, api.post).__handler__(api, ctx, "app-1", body=PublishPayload(marked_name="v2")) + + stored_app = sqlite_session.get(App, APP_ID) + assert stored_app is not None + assert [publish["marked_name"] for publish in publishes] == ["v2"] + assert (stored_app.workflow_id, stored_app.updated_by) == (VERSION_ID, ACCOUNT_ID) + assert stored_app.updated_at is not None + assert cast(PublishResponse, response).version_id == VERSION_ID + assert cast(PublishResponse, response).warning is None + + +def test_publish_warns_about_references_to_a_skipped_branch( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + service = _workflow_service(monkeypatch, sqlite_session_factory) + monkeypatch.setattr(service, "publish_workflow", lambda **_kwargs: _published_version(_branch_skipping_graph())) + ctx = _app_context(sqlite_session) + + api = AppPublishApi() + with app.test_request_context("/openapi/v1/apps/app-1:publish", method="POST"): + response = cast(_EndpointView, api.post).__handler__(api, ctx, "app-1", body=PublishPayload()) + + warning = cast(PublishResponse, response).warning + assert warning is not None + assert "Consumer" in warning + assert "Producer" in warning + + +def test_publish_refusal_is_a_bad_request( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + service = _workflow_service(monkeypatch, sqlite_session_factory) + + def publish_workflow(**_kwargs: object) -> Workflow: + raise ValueError("Node l (llm): Missing provider or model configuration") + + monkeypatch.setattr(service, "publish_workflow", publish_workflow) + ctx = _app_context(sqlite_session) + + api = AppPublishApi() + with app.test_request_context("/openapi/v1/apps/app-1:publish", method="POST"): + with pytest.raises(BadRequest, match="Missing provider"): + cast(_EndpointView, api.post).__handler__(api, ctx, "app-1", body=PublishPayload()) + + stored_app = sqlite_session.get(App, APP_ID) + assert stored_app is not None + assert stored_app.workflow_id is None diff --git a/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py b/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py index 1fa710d24ab..a43cd441f64 100644 --- a/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py +++ b/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py @@ -62,6 +62,7 @@ from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from enum import StrEnum, auto from functools import singledispatch +from typing import Final from unittest.mock import patch import pytest @@ -76,16 +77,22 @@ import libs.oauth_bearer as oauth_bearer_module import libs.rate_limit as rate_limit_module from app_factory import create_flask_app_with_configs from constants.oauth_bearer import Scope, TokenType -from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, Workspace +from controllers.common.rbac import AgentBehindApp, PlainApp, RBACCheck, RBACPermission, Workspace from controllers.openapi import bp as openapi_bp +from controllers.openapi._app_settings import REGULAR_MODES from controllers.openapi._catalog import CATALOG_HEADER, catalog_for +from controllers.openapi._errors import ServiceApiDisabled, WebAppAccessRequiresEE from controllers.openapi.auth import subjects from controllers.openapi.auth.requirements import ( CheckAppAccess, CheckAppApiEnabled, + CheckAppMode, + CheckAppQuota, + CheckPluginInstallSetting, CheckRBACPermission, CheckScope, CheckSubject, + CheckWebAppAuthEnterprise, CheckWorkspaceInvitationQuota, CheckWorkspaceMember, CheckWorkspaceRole, @@ -99,7 +106,7 @@ from extensions.ext_application_services import ApplicationServices from libs.oauth_bearer import BearerAuthenticator, ResolvedRow, sha256_hex from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole from models.enums import EndUserType -from models.model import App, EndUser +from models.model import App, AppMode, EndUser from models.oauth import OAuthAccessToken from services.enterprise.enterprise_service import EnterpriseService from services.entities.feature_entities import LicenseStatus, SystemFeatureModel @@ -211,7 +218,7 @@ DENY_SSO_NEEDS_EE = Expect(403, "external_sso_requires_ee") DENY_SCOPE = Expect(403, "insufficient_scope") DENY_NON_MEMBER = Expect(404, "workspace not found") DENY_ROLE = Expect(403, "insufficient workspace role") -DENY_API_DISABLED = Expect(403, "service_api_disabled") +DENY_API_DISABLED = Expect(403, ServiceApiDisabled.description) DENY_UNKNOWN_APP = Expect(404, "app not found") DENY_ACCESS_MODE = Expect(403, "subject_not_allowed_for_access_mode") DENY_MODE_UNRESOLVED = Expect(403, "app or access mode not loaded") @@ -262,7 +269,6 @@ ROUTES: tuple[Route, ...] = ( Route("apps.list", "GET", "/apps", frozenset({Trait.ACCOUNT_PRIMARY}), query="workspace_id={workspace_id}"), Route("workspaces.list", "GET", "/workspaces", frozenset({Trait.ACCOUNT_PRIMARY})), Route("workspaces.describe", "GET", "/workspaces/{workspace_id}", frozenset({Trait.ACCOUNT_PRIMARY})), - Route("workspaces.switch", "POST", "/workspaces/{workspace_id}:switch", frozenset({Trait.ACCOUNT_PRIMARY})), Route("workspaces.members.list", "GET", "/workspaces/{workspace_id}/members", frozenset({Trait.ACCOUNT_PRIMARY})), Route( "workspaces.members.invite", @@ -282,12 +288,99 @@ ROUTES: tuple[Route, ...] = ( "/workspaces/{workspace_id}/members/{member_id}", frozenset({Trait.ACCOUNT_PRIMARY}), ), + Route( + "plugins.marketplace", + "GET", + "/workspaces/{workspace_id}/marketplace/plugins", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route("plugins.list", "GET", "/workspaces/{workspace_id}/plugins", frozenset({Trait.ACCOUNT_PRIMARY})), + Route("plugins.install", "POST", "/workspaces/{workspace_id}/plugins:install", frozenset({Trait.ACCOUNT_PRIMARY})), + Route( + "plugins.task", + "GET", + "/workspaces/{workspace_id}/plugin-tasks/{task_id}", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "plugins.delete", + "DELETE", + "/workspaces/{workspace_id}/plugins/{plugin_id}", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "model_providers.describe", + "GET", + "/workspaces/{workspace_id}/model-providers/{provider}", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "model_providers.credential.create", + "POST", + "/workspaces/{workspace_id}/model-providers/{provider}/credentials", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "model_providers.credential.set", + "PATCH", + "/workspaces/{workspace_id}/model-providers/{provider}/credentials/{credential_id}", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route("tools.list", "GET", "/workspaces/{workspace_id}/tools", frozenset({Trait.ACCOUNT_PRIMARY})), + Route( + "tool_providers.describe", + "GET", + "/workspaces/{workspace_id}/tool-providers/{provider}", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "tool_providers.credential.create", + "POST", + "/workspaces/{workspace_id}/tool-providers/{provider}/credentials", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "tool_providers.credential.set", + "PATCH", + "/workspaces/{workspace_id}/tool-providers/{provider}/credentials/{credential_id}", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "knowledge_bases.list", + "GET", + "/workspaces/{workspace_id}/knowledge-bases", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "models.list", + "GET", + "/workspaces/{workspace_id}/models", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "models.credential.create", + "POST", + "/workspaces/{workspace_id}/model-providers/{provider}/models/credentials", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "models.credential.set", + "PATCH", + "/workspaces/{workspace_id}/model-providers/{provider}/models/credentials/{credential_id}", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), Route( "app_dsl.import", "POST", "/workspaces/{workspace_id}/apps/imports", frozenset({Trait.ACCOUNT_PRIMARY}), ), + Route( + "app_dsl.check", + "POST", + "/workspaces/{workspace_id}/apps/imports:check", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), Route( "app_dsl.import_confirm", "POST", @@ -310,6 +403,297 @@ ROUTES: tuple[Route, ...] = ( Route("app_run.chat", "POST", "/apps/{app_id}/chat:run", _RUN_TRAITS), Route("app_run.advanced_chat", "POST", "/apps/{app_id}/advanced-chat:run", _RUN_TRAITS), Route("app_run.completion", "POST", "/apps/{app_id}/completion:run", _RUN_TRAITS), + Route( + "app_run.draft.workflow", + "POST", + "/apps/{app_id}/draft/workflow:run", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_run.draft.advanced_chat", + "POST", + "/apps/{app_id}/draft/advanced-chat:run", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route("app_workflow.run.list", "GET", "/apps/{app_id}/runs", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})), + Route( + "app_workflow.run.describe", + "GET", + "/apps/{app_id}/runs/{run_id}", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_workflow.run.nodes", + "GET", + "/apps/{app_id}/runs/{run_id}/nodes", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_workflow.publish", "POST", "/apps/{app_id}:publish", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}) + ), + Route( + "app_workflow.version.list", + "GET", + "/apps/{app_id}/versions", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_workflow.version.restore", + "POST", + "/apps/{app_id}/versions/{version_id}:restore", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_workflow.release_check", + "GET", + "/apps/{app_id}/release:check", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route("app_workflow.env.list", "GET", "/apps/{app_id}/env", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})), + Route( + "app_workflow.env.set", + "PUT", + "/apps/{app_id}/env/{env_id}", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_workflow.env.delete", + "DELETE", + "/apps/{app_id}/env/{env_id}", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route("node_types.list", "GET", "/node-types", frozenset({Trait.ACCOUNT_PRIMARY, Trait.EXTERNAL_REACHABLE})), + Route( + "node_types.describe", + "GET", + "/node-types/{node_type}", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.EXTERNAL_REACHABLE}), + ), + Route( + "app_create.workflow", "POST", "/workspaces/{workspace_id}/apps/workflow", frozenset({Trait.ACCOUNT_PRIMARY}) + ), + Route( + "app_create.advanced_chat", + "POST", + "/workspaces/{workspace_id}/apps/advanced-chat", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "app_workflow.node_run.workflow", + "POST", + "/apps/{app_id}/draft/workflow/nodes/{node_id}:run", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_workflow.node_run.advanced_chat", + "POST", + "/apps/{app_id}/draft/advanced-chat/nodes/{node_id}:run", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.describe.workflow", + "GET", + "/apps/{app_id}/app-info/workflow", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.set.workflow", + "PATCH", + "/apps/{app_id}/app-info/workflow", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "service_api.describe", + "GET", + "/apps/{app_id}/service-api", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "service_api.set", + "PATCH", + "/apps/{app_id}/service-api", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.describe.advanced_chat", + "GET", + "/apps/{app_id}/app-info/advanced-chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.set.advanced_chat", + "PATCH", + "/apps/{app_id}/app-info/advanced-chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.describe.chat", + "GET", + "/apps/{app_id}/app-info/chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.set.chat", + "PATCH", + "/apps/{app_id}/app-info/chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.describe.agent_chat", + "GET", + "/apps/{app_id}/app-info/agent-chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.set.agent_chat", + "PATCH", + "/apps/{app_id}/app-info/agent-chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.describe.completion", + "GET", + "/apps/{app_id}/app-info/completion", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.set.completion", + "PATCH", + "/apps/{app_id}/app-info/completion", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.describe.agent", + "GET", + "/apps/{app_id}/app-info/agent", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_info.set.agent", + "PATCH", + "/apps/{app_id}/app-info/agent", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "service_api.describe.agent", + "GET", + "/apps/{app_id}/service-api/agent", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "service_api.set.agent", + "PATCH", + "/apps/{app_id}/service-api/agent", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.describe.workflow", + "GET", + "/apps/{app_id}/webapp/workflow", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.set.workflow", + "PATCH", + "/apps/{app_id}/webapp/workflow", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.reset", + "POST", + "/apps/{app_id}/webapp:reset", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp_access.describe", + "GET", + "/apps/{app_id}/webapp-access", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp_access.set", + "PUT", + "/apps/{app_id}/webapp-access", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.describe.advanced_chat", + "GET", + "/apps/{app_id}/webapp/advanced-chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.set.advanced_chat", + "PATCH", + "/apps/{app_id}/webapp/advanced-chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.describe.chat", + "GET", + "/apps/{app_id}/webapp/chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.set.chat", + "PATCH", + "/apps/{app_id}/webapp/chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.describe.agent_chat", + "GET", + "/apps/{app_id}/webapp/agent-chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.set.agent_chat", + "PATCH", + "/apps/{app_id}/webapp/agent-chat", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.describe.completion", + "GET", + "/apps/{app_id}/webapp/completion", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.set.completion", + "PATCH", + "/apps/{app_id}/webapp/completion", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.describe.agent", + "GET", + "/apps/{app_id}/webapp/agent", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.set.agent", + "PATCH", + "/apps/{app_id}/webapp/agent", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp.reset.agent", + "POST", + "/apps/{app_id}/webapp/agent:reset", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp_access.describe.agent", + "GET", + "/apps/{app_id}/webapp-access/agent", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "webapp_access.set.agent", + "PUT", + "/apps/{app_id}/webapp-access/agent", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), Route( "app_run.stop", "POST", @@ -564,6 +948,55 @@ _DUAL_SUBJECT_RUN: dict[Case, Expect] = { } +_ACCOUNT_READER_APP: dict[Case, Expect] = { + Case.MEMBER: ADMIT, + Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT, + Case.INSUFFICIENT_SCOPE: DENY_SCOPE, + Case.NON_MEMBER: DENY_NON_MEMBER, + Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER, + Case.LOW_ROLE: ADMIT, + Case.APP_API_DISABLED: DENY_API_DISABLED, + Case.UNKNOWN_APP: DENY_UNKNOWN_APP, + Case.FOREIGN_WORKSPACE_QUERY: ADMIT, + Case.EE_ACCOUNT_PUBLIC: ADMIT, + Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT, + Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT, + Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT, + Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT, + Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT, + Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT, + Case.RBAC_ON_DENIED: DENY_RBAC, + Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE, +} + +_ACCOUNT_EDITOR_APP: dict[Case, Expect] = { + **_ACCOUNT_READER_APP, + Case.LOW_ROLE: DENY_ROLE, + Case.RBAC_ON_LOW_ROLE: ADMIT, +} + +_ACCOUNT_READER_SETTINGS: dict[Case, Expect] = {**_ACCOUNT_READER_APP, Case.APP_API_DISABLED: ADMIT} +_ACCOUNT_EDITOR_SETTINGS: dict[Case, Expect] = {**_ACCOUNT_EDITOR_APP, Case.APP_API_DISABLED: ADMIT} + +DENY_NEEDS_WEBAPP_EE = Expect(403, WebAppAccessRequiresEE.description) + +_WEBAPP_ACCESS_EDITOR: dict[Case, Expect] = { + **_ACCOUNT_EDITOR_SETTINGS, + Case.MEMBER: DENY_NEEDS_WEBAPP_EE, + Case.APP_API_DISABLED: DENY_NEEDS_WEBAPP_EE, + Case.FOREIGN_WORKSPACE_QUERY: DENY_NEEDS_WEBAPP_EE, + Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: DENY_NEEDS_WEBAPP_EE, + Case.RBAC_ON_LOW_ROLE: DENY_NEEDS_WEBAPP_EE, +} + + +_ANY_BEARER: dict[Case, Expect] = { + **{case: ADMIT for case in Case if CASE_REQUIRES[case] <= {Trait.ACCOUNT_PRIMARY, Trait.EXTERNAL_REACHABLE}}, + Case.WRONG_SUBJECT: DENY_SSO_NEEDS_EE, +} +"""A route with no requirements admits every case that carries a valid bearer. The one +exception is the router's own answer: an external-SSO bearer is refused outside enterprise.""" + MATRIX: dict[str, dict[Case, Expect]] = { "describe.account": dict(_ACCOUNT_ONLY_NO_WORKSPACE), "workspaces.list": dict(_ACCOUNT_ONLY_NO_WORKSPACE), @@ -573,47 +1006,34 @@ MATRIX: dict[str, dict[Case, Expect]] = { # (matches check_member_permission's own add/remove vs update split) "workspaces.members.invite": dict(_ACCOUNT_MEMBER_WITH_ROLE), "workspaces.members.update_role": dict(_ACCOUNT_MEMBER_WITH_ROLE), + "plugins.install": {**_ACCOUNT_MEMBER_NO_ROLE, Case.RBAC_ON_DENIED: DENY_RBAC}, + "model_providers.credential.create": dict(_ACCOUNT_MEMBER_WITH_ROLE), "app_dsl.import": dict(_ACCOUNT_MEMBER_WITH_ROLE), - "apps.describe": { - Case.MEMBER: ADMIT, - Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT, - Case.INSUFFICIENT_SCOPE: DENY_SCOPE, - Case.NON_MEMBER: DENY_NON_MEMBER, - Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER, - Case.LOW_ROLE: ADMIT, - Case.APP_API_DISABLED: DENY_API_DISABLED, - Case.UNKNOWN_APP: DENY_UNKNOWN_APP, - Case.FOREIGN_WORKSPACE_QUERY: ADMIT, - Case.EE_ACCOUNT_PUBLIC: ADMIT, - Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT, - Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT, - Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT, - Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT, - Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT, - Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT, - Case.RBAC_ON_DENIED: DENY_RBAC, - Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE, - }, - "app_dsl.export": { - Case.MEMBER: ADMIT, - Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT, - Case.INSUFFICIENT_SCOPE: DENY_SCOPE, - Case.NON_MEMBER: DENY_NON_MEMBER, - Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER, - Case.LOW_ROLE: DENY_ROLE, - Case.APP_API_DISABLED: DENY_API_DISABLED, - Case.UNKNOWN_APP: DENY_UNKNOWN_APP, - Case.FOREIGN_WORKSPACE_QUERY: ADMIT, - Case.EE_ACCOUNT_PUBLIC: ADMIT, - Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT, - Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT, - Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT, - Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT, - Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT, - Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT, - Case.RBAC_ON_LOW_ROLE: ADMIT, - Case.RBAC_ON_DENIED: DENY_RBAC, - }, + "app_dsl.check": dict(_ACCOUNT_MEMBER_WITH_ROLE), + "app_create.workflow": dict(_ACCOUNT_MEMBER_WITH_ROLE), + "node_types.list": dict(_ANY_BEARER), + "app_workflow.node_run.workflow": dict(_ACCOUNT_EDITOR_APP), + "app_info.describe.workflow": dict(_ACCOUNT_READER_SETTINGS), + "app_info.set.workflow": dict(_ACCOUNT_EDITOR_SETTINGS), + "service_api.describe": dict(_ACCOUNT_EDITOR_SETTINGS), + "service_api.set": dict(_ACCOUNT_EDITOR_SETTINGS), + "webapp.describe.workflow": dict(_ACCOUNT_READER_SETTINGS), + "webapp.set.workflow": dict(_ACCOUNT_EDITOR_SETTINGS), + "webapp.reset": dict(_ACCOUNT_EDITOR_SETTINGS), + "webapp_access.describe": dict(_WEBAPP_ACCESS_EDITOR), + "apps.describe": dict(_ACCOUNT_READER_APP), + "app_dsl.export": dict(_ACCOUNT_EDITOR_APP), + "app_run.draft.workflow": dict(_ACCOUNT_EDITOR_APP), + "app_workflow.run.list": dict(_ACCOUNT_READER_APP), + "app_workflow.run.describe": dict(_ACCOUNT_READER_APP), + "app_workflow.run.nodes": dict(_ACCOUNT_READER_APP), + "app_workflow.publish": dict(_ACCOUNT_EDITOR_APP), + "app_workflow.version.list": dict(_ACCOUNT_EDITOR_APP), + "app_workflow.version.restore": dict(_ACCOUNT_EDITOR_APP), + "app_workflow.release_check": dict(_ACCOUNT_EDITOR_APP), + "app_workflow.env.list": dict(_ACCOUNT_EDITOR_APP), + "app_workflow.env.set": dict(_ACCOUNT_EDITOR_APP), + "app_workflow.env.delete": dict(_ACCOUNT_EDITOR_APP), "app_run.workflow": dict(_DUAL_SUBJECT_RUN), "app_run.chat": dict(_DUAL_SUBJECT_RUN), "app_run.advanced_chat": dict(_DUAL_SUBJECT_RUN), @@ -717,6 +1137,64 @@ _REQ_RUN = ( CheckAppAccess(), ) _REQ_RUN_FORM = (*_REQ_RUN, CheckFormSurface()) +_REQ_RELEASE = ( + CheckSubject(allowed=_ACCOUNT), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckAppMode(AppMode.WORKFLOW, AppMode.ADVANCED_CHAT), + CheckScope(Scope.WORKSPACE_WRITE), + CheckRBACPermission(RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp())), + CheckWorkspaceRole(_EDITOR_UP), +) +_REQ_VERSION_READ = ( + CheckSubject(allowed=_ACCOUNT), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckAppMode(AppMode.WORKFLOW, AppMode.ADVANCED_CHAT), + CheckScope(Scope.APPS_READ), + CheckRBACPermission(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())), + CheckWorkspaceRole(_EDITOR_UP), +) +_REQ_ENV_WRITE = ( + CheckSubject(allowed=_ACCOUNT), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckAppMode(AppMode.WORKFLOW, AppMode.ADVANCED_CHAT), + CheckScope(Scope.WORKSPACE_WRITE), + CheckRBACPermission(RBACCheck(RBACPermission.APP_EDIT, PlainApp())), + CheckWorkspaceRole(_EDITOR_UP), +) +_REQ_RUN_HISTORY = ( + CheckSubject(allowed=_ACCOUNT), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckAppMode(AppMode.WORKFLOW, AppMode.ADVANCED_CHAT), + CheckScope(Scope.APPS_READ), + CheckRBACPermission(RBACCheck(RBACPermission.APP_CREATE_AND_MANAGEMENT, PlainApp())), +) +_REQ_DRAFT_RUN = ( + CheckSubject(allowed=_ACCOUNT), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_RUN), + CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), + CheckWorkspaceRole(_EDITOR_UP), +) + + +def _draft_req(mode: AppMode) -> tuple[Requirement, ...]: + return (*_REQ_DRAFT_RUN[:3], CheckAppMode(mode), *_REQ_DRAFT_RUN[3:]) + + +_REQ_NODE_TYPES: tuple[Requirement, ...] = () +_REQ_APP_CREATE = ( + CheckSubject(allowed=_ACCOUNT), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(RBACPermission.APP_CREATE_AND_MANAGEMENT, Workspace())), + CheckWorkspaceRole(_EDITOR_UP), + CheckAppQuota(), +) _REQ_FILES = ( CheckSubject(allowed=_ACCOUNT_OR_EXTERNAL), CheckAppApiEnabled(), @@ -735,6 +1213,52 @@ _REQ_EXTERNAL_DESCRIBE = ( CheckAppAccess(), ) + +def _req_plugin_write(permission: RBACPermission) -> tuple[Requirement, ...]: + return ( + CheckSubject(allowed=_ACCOUNT), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(permission, Workspace())), + CheckPluginInstallSetting(), + ) + + +def _req_admin_write(permission: RBACPermission) -> tuple[Requirement, ...]: + return ( + CheckSubject(allowed=_ACCOUNT), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(permission, Workspace())), + CheckWorkspaceRole(_OWNER_ADMIN), + ) + + +_REQ_PLUGIN_TASK = ( + CheckSubject(allowed=_ACCOUNT), + CheckScope(Scope.WORKSPACE_READ), + CheckWorkspaceMember(), + CheckPluginInstallSetting(), +) + + +def _settings_req( + modes: tuple[AppMode, ...], + perm: RBACPermission, + locator: PlainApp | AgentBehindApp, + scope: Scope, + roles: frozenset[TenantAccountRole] | None, +) -> tuple[Requirement, ...]: + base: tuple[Requirement, ...] = ( + CheckSubject(allowed=_ACCOUNT), + CheckWorkspaceMember(), + CheckAppMode(*modes), + CheckScope(scope), + CheckRBACPermission(RBACCheck(perm, locator)), + ) + return (*base, CheckWorkspaceRole(roles)) if roles is not None else base + + _RESOURCE_RUN = (CheckSubject(allowed=(*_ACCOUNT_OR_EXTERNAL, ResourceAccessSubject)), *_REQ_RUN[1:]) DECLARED: dict[str, tuple[Requirement, ...]] = { @@ -754,7 +1278,24 @@ DECLARED: dict[str, tuple[Requirement, ...]] = { "workspaces.members.invite": (*_REQ_MEMBER_MANAGE, CheckWorkspaceInvitationQuota()), "workspaces.members.remove": _REQ_MEMBER_MANAGE, "workspaces.members.update_role": _REQ_ROLE_MANAGE, + "plugins.marketplace": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, + "plugins.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, + "plugins.install": _req_plugin_write(RBACPermission.PLUGIN_INSTALL), + "plugins.task": _REQ_PLUGIN_TASK, + "plugins.delete": _req_plugin_write(RBACPermission.PLUGIN_DELETE), + "model_providers.describe": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, + "model_providers.credential.create": _req_admin_write(RBACPermission.CREDENTIAL_CREATE), + "model_providers.credential.set": _req_admin_write(RBACPermission.CREDENTIAL_MANAGE), + "tools.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, + "tool_providers.describe": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, + "tool_providers.credential.create": _req_admin_write(RBACPermission.CREDENTIAL_CREATE), + "tool_providers.credential.set": _req_admin_write(RBACPermission.CREDENTIAL_MANAGE), + "knowledge_bases.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, + "models.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, + "models.credential.create": _req_admin_write(RBACPermission.CREDENTIAL_CREATE), + "models.credential.set": _req_admin_write(RBACPermission.CREDENTIAL_MANAGE), "app_dsl.import": _REQ_DSL_WORKSPACE, + "app_dsl.check": _REQ_DSL_WORKSPACE, "app_dsl.import_confirm": _REQ_DSL_WORKSPACE, "app_dsl.export": _REQ_DSL_APP, "app_dsl.check_dependencies": _REQ_DSL_APP, @@ -762,6 +1303,142 @@ DECLARED: dict[str, tuple[Requirement, ...]] = { "app_run.chat": _RESOURCE_RUN, "app_run.advanced_chat": _RESOURCE_RUN, "app_run.completion": _RESOURCE_RUN, + "app_run.draft.workflow": _draft_req(AppMode.WORKFLOW), + "app_run.draft.advanced_chat": _draft_req(AppMode.ADVANCED_CHAT), + "app_workflow.run.list": _REQ_RUN_HISTORY, + "app_workflow.run.describe": _REQ_RUN_HISTORY, + "app_workflow.run.nodes": _REQ_RUN_HISTORY, + "app_workflow.publish": _REQ_RELEASE, + "app_workflow.version.list": _REQ_VERSION_READ, + "app_workflow.version.restore": _REQ_RELEASE, + "app_workflow.release_check": _REQ_VERSION_READ, + "app_workflow.env.list": _REQ_VERSION_READ, + "app_workflow.env.set": _REQ_ENV_WRITE, + "app_workflow.env.delete": _REQ_ENV_WRITE, + "node_types.list": _REQ_NODE_TYPES, + "node_types.describe": _REQ_NODE_TYPES, + "app_create.workflow": _REQ_APP_CREATE, + "app_create.advanced_chat": _REQ_APP_CREATE, + "app_workflow.node_run.workflow": _draft_req(AppMode.WORKFLOW), + "app_workflow.node_run.advanced_chat": _draft_req(AppMode.ADVANCED_CHAT), + "app_info.describe.workflow": _settings_req( + (AppMode.WORKFLOW,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None + ), + "app_info.set.workflow": _settings_req( + (AppMode.WORKFLOW,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "service_api.describe": _settings_req( + REGULAR_MODES, RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.APPS_READ, _EDITOR_UP + ), + "service_api.set": _settings_req( + REGULAR_MODES, RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _OWNER_ADMIN + ), + "app_info.describe.advanced_chat": _settings_req( + (AppMode.ADVANCED_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None + ), + "app_info.set.advanced_chat": _settings_req( + (AppMode.ADVANCED_CHAT,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "app_info.describe.chat": _settings_req( + (AppMode.CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None + ), + "app_info.set.chat": _settings_req( + (AppMode.CHAT,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "app_info.describe.agent_chat": _settings_req( + (AppMode.AGENT_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None + ), + "app_info.set.agent_chat": _settings_req( + (AppMode.AGENT_CHAT,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "app_info.describe.completion": _settings_req( + (AppMode.COMPLETION,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None + ), + "app_info.set.completion": _settings_req( + (AppMode.COMPLETION,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "app_info.describe.agent": _settings_req( + (AppMode.AGENT,), RBACPermission.AGENT_PREVIEW, AgentBehindApp(), Scope.APPS_READ, None + ), + "app_info.set.agent": _settings_req( + (AppMode.AGENT,), RBACPermission.AGENT_EDIT, AgentBehindApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "service_api.describe.agent": _settings_req( + (AppMode.AGENT,), RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp(), Scope.APPS_READ, _EDITOR_UP + ), + "service_api.set.agent": _settings_req( + (AppMode.AGENT,), + RBACPermission.AGENT_ACCESS_POINT_MANAGE, + AgentBehindApp(), + Scope.WORKSPACE_WRITE, + _OWNER_ADMIN, + ), + "webapp.describe.workflow": _settings_req( + (AppMode.WORKFLOW,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None + ), + "webapp.set.workflow": _settings_req( + (AppMode.WORKFLOW,), RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "webapp.reset": _settings_req( + REGULAR_MODES, RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _OWNER_ADMIN + ), + "webapp_access.describe": ( + *_settings_req(REGULAR_MODES, RBACPermission.APP_ACCESS_CONFIG, PlainApp(), Scope.APPS_READ, _EDITOR_UP), + CheckWebAppAuthEnterprise(), + ), + "webapp_access.set": ( + *_settings_req(REGULAR_MODES, RBACPermission.APP_ACCESS_CONFIG, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP), + CheckWebAppAuthEnterprise(), + ), + "webapp.describe.advanced_chat": _settings_req( + (AppMode.ADVANCED_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None + ), + "webapp.set.advanced_chat": _settings_req( + (AppMode.ADVANCED_CHAT,), RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "webapp.describe.chat": _settings_req( + (AppMode.CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None + ), + "webapp.set.chat": _settings_req( + (AppMode.CHAT,), RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "webapp.describe.agent_chat": _settings_req( + (AppMode.AGENT_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None + ), + "webapp.set.agent_chat": _settings_req( + (AppMode.AGENT_CHAT,), RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "webapp.describe.completion": _settings_req( + (AppMode.COMPLETION,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None + ), + "webapp.set.completion": _settings_req( + (AppMode.COMPLETION,), RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "webapp.describe.agent": _settings_req( + (AppMode.AGENT,), RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp(), Scope.APPS_READ, None + ), + "webapp.set.agent": _settings_req( + (AppMode.AGENT,), RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + "webapp.reset.agent": _settings_req( + (AppMode.AGENT,), + RBACPermission.AGENT_ACCESS_POINT_MANAGE, + AgentBehindApp(), + Scope.WORKSPACE_WRITE, + _OWNER_ADMIN, + ), + "webapp_access.describe.agent": ( + *_settings_req( + (AppMode.AGENT,), RBACPermission.AGENT_ACCESS_CONFIG, AgentBehindApp(), Scope.APPS_READ, _EDITOR_UP + ), + CheckWebAppAuthEnterprise(), + ), + "webapp_access.set.agent": ( + *_settings_req( + (AppMode.AGENT,), RBACPermission.AGENT_ACCESS_CONFIG, AgentBehindApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP + ), + CheckWebAppAuthEnterprise(), + ), "app_run.stop": _RESOURCE_RUN, "files.upload": _REQ_FILES, "human_input_form.get": _REQ_RUN_FORM, @@ -1052,6 +1729,15 @@ def _url(route: Route, world: World, scenario: Scenario, bearer: Bearer | None) "import_id": str(uuid.uuid4()), "task_id": str(uuid.uuid4()), "form_token": uuid.uuid4().hex, + "run_id": str(uuid.uuid4()), + "version_id": str(uuid.uuid4()), + "env_id": str(uuid.uuid4()), + "node_type": "llm", + "node_id": "node-1", + "plugin_id": "langgenius/openai", + "provider": "langgenius/openai/openai", + "credential_id": str(uuid.uuid4()), + "model_type": "llm", } query = route.query.format(**ids) if scenario.foreign_workspace_query: @@ -1169,8 +1855,14 @@ def test_allow_deny_matrix( assert body.get("message") == expected.message +_PATH_PARAMS: Final = frozenset({"plugin_id", "provider"}) + + def _rule_path(route: Route) -> str: - return "/openapi/v1" + route.path.replace("{", "") + path = route.path + for name in _PATH_PARAMS: + path = path.replace(f"{{{name}}}", f"") + return "/openapi/v1" + path.replace("{", "") def _endpoint_spec(app: Flask, rule: Rule, method: str) -> EndpointSpec | None: @@ -1225,6 +1917,11 @@ def _(requirement: CheckSubject) -> object: return requirement.allowed +@_config.register +def _(requirement: CheckAppMode) -> object: + return requirement.modes + + @_config.register def _(requirement: CheckScope) -> object: return requirement.scope @@ -1271,6 +1968,30 @@ ERROR_DEFAULT_RESPONSE: dict[str, object] = { EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = { ("get", "/_catalog"): frozenset({"200", "default"}), + ("get", "/workspaces/{workspace_id}/marketplace/plugins"): frozenset({"200", "422", "default"}), + ("get", "/workspaces/{workspace_id}/plugins"): frozenset({"200", "422", "default"}), + ("post", "/workspaces/{workspace_id}/plugins:install"): frozenset({"200", "422", "default"}), + ("get", "/workspaces/{workspace_id}/plugin-tasks/{task_id}"): frozenset({"200", "default"}), + ("delete", "/workspaces/{workspace_id}/plugins/{plugin_id}"): frozenset({"200", "default"}), + ("get", "/workspaces/{workspace_id}/tools"): frozenset({"200", "422", "default"}), + ("get", "/workspaces/{workspace_id}/tool-providers/{provider}"): frozenset({"200", "default"}), + ("post", "/workspaces/{workspace_id}/tool-providers/{provider}/credentials"): frozenset({"201", "422", "default"}), + ("patch", "/workspaces/{workspace_id}/tool-providers/{provider}/credentials/{credential_id}"): frozenset( + {"200", "422", "default"} + ), + ("get", "/workspaces/{workspace_id}/model-providers/{provider}"): frozenset({"200", "default"}), + ("post", "/workspaces/{workspace_id}/model-providers/{provider}/credentials"): frozenset({"201", "422", "default"}), + ("patch", "/workspaces/{workspace_id}/model-providers/{provider}/credentials/{credential_id}"): frozenset( + {"200", "422", "default"} + ), + ("get", "/workspaces/{workspace_id}/knowledge-bases"): frozenset({"200", "422", "default"}), + ("get", "/workspaces/{workspace_id}/models"): frozenset({"200", "422", "default"}), + ("post", "/workspaces/{workspace_id}/model-providers/{provider}/models/credentials"): frozenset( + {"201", "422", "default"} + ), + ("patch", "/workspaces/{workspace_id}/model-providers/{provider}/models/credentials/{credential_id}"): frozenset( + {"200", "422", "default"} + ), ("get", "/_health"): frozenset({"200", "default"}), ("get", "/_version"): frozenset({"200", "default"}), ("get", "/account"): frozenset({"200", "default"}), @@ -1279,6 +2000,22 @@ EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = { ("delete", "/account/sessions/{session_id}"): frozenset({"200", "default"}), ("get", "/apps"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/app-info/workflow"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/app-info/workflow"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/service-api"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/service-api"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/app-info/advanced-chat"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/app-info/advanced-chat"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/app-info/chat"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/app-info/chat"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/app-info/agent-chat"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/app-info/agent-chat"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/app-info/completion"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/app-info/completion"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/app-info/agent"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/app-info/agent"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/service-api/agent"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/service-api/agent"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/dependencies:check"): frozenset({"200", "default"}), ("get", "/apps/{app_id}/dsl"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/files"): frozenset({"201", "400", "401", "413", "415", "422", "default"}), @@ -1290,6 +2027,18 @@ EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = { ("post", "/apps/{app_id}/chat:run"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/advanced-chat:run"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/completion:run"): frozenset({"200", "422", "default"}), + ("post", "/apps/{app_id}/draft/workflow:run"): frozenset({"200", "422", "default"}), + ("post", "/apps/{app_id}/draft/advanced-chat:run"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/runs"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/runs/{run_id}"): frozenset({"200", "default"}), + ("get", "/apps/{app_id}/runs/{run_id}/nodes"): frozenset({"200", "default"}), + ("post", "/apps/{app_id}:publish"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/versions"): frozenset({"200", "422", "default"}), + ("post", "/apps/{app_id}/versions/{version_id}:restore"): frozenset({"200", "default"}), + ("get", "/apps/{app_id}/release:check"): frozenset({"200", "default"}), + ("get", "/apps/{app_id}/env"): frozenset({"200", "default"}), + ("put", "/apps/{app_id}/env/{env_id}"): frozenset({"200", "422", "default"}), + ("delete", "/apps/{app_id}/env/{env_id}"): frozenset({"200", "default"}), # The catalog and the five device-flow rows document their 200 with a raw # `openapi_ns.response` rather than `@returns`; `ExternalApi` still gives them the # `default` error every operation documents, with the same `ErrorBody` schema. @@ -1303,17 +2052,41 @@ EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = { ("post", "/oauth/device/deny"): frozenset({"200", "404", "409", "503", "default"}), ("get", "/oauth/device/lookup"): frozenset({"200", "default"}), ("post", "/oauth/device/token"): frozenset({"200", "400", "default"}), + ("get", "/node-types"): frozenset({"200", "default"}), + ("get", "/node-types/{node_type}"): frozenset({"200", "default"}), + ("post", "/apps/{app_id}/draft/workflow/nodes/{node_id}:run"): frozenset({"200", "422", "default"}), + ("post", "/apps/{app_id}/draft/advanced-chat/nodes/{node_id}:run"): frozenset({"200", "422", "default"}), + ("post", "/workspaces/{workspace_id}/apps/workflow"): frozenset({"201", "422", "default"}), + ("post", "/workspaces/{workspace_id}/apps/advanced-chat"): frozenset({"201", "422", "default"}), ("get", "/permitted-external-apps"): frozenset({"200", "422", "default"}), ("get", "/permitted-external-apps/{app_id}"): frozenset({"200", "422", "default"}), ("get", "/workspaces"): frozenset({"200", "422", "default"}), ("get", "/workspaces/{workspace_id}"): frozenset({"200", "default"}), ("post", "/workspaces/{workspace_id}/apps/imports"): frozenset({"200", "202", "400", "422", "default"}), + ("post", "/workspaces/{workspace_id}/apps/imports:check"): frozenset({"200", "422", "default"}), ("post", "/workspaces/{workspace_id}/apps/imports/{import_id}:confirm"): frozenset({"200", "400", "default"}), ("get", "/workspaces/{workspace_id}/members"): frozenset({"200", "422", "default"}), ("post", "/workspaces/{workspace_id}/members"): frozenset({"201", "422", "default"}), ("delete", "/workspaces/{workspace_id}/members/{member_id}"): frozenset({"200", "default"}), ("patch", "/workspaces/{workspace_id}/members/{member_id}"): frozenset({"200", "422", "default"}), - ("post", "/workspaces/{workspace_id}:switch"): frozenset({"200", "default"}), + ("get", "/apps/{app_id}/webapp/workflow"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/webapp/workflow"): frozenset({"200", "422", "default"}), + ("post", "/apps/{app_id}/webapp:reset"): frozenset({"200", "default"}), + ("get", "/apps/{app_id}/webapp-access"): frozenset({"200", "default"}), + ("put", "/apps/{app_id}/webapp-access"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/webapp/advanced-chat"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/webapp/advanced-chat"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/webapp/chat"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/webapp/chat"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/webapp/agent-chat"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/webapp/agent-chat"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/webapp/completion"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/webapp/completion"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/webapp/agent"): frozenset({"200", "default"}), + ("patch", "/apps/{app_id}/webapp/agent"): frozenset({"200", "422", "default"}), + ("post", "/apps/{app_id}/webapp/agent:reset"): frozenset({"200", "default"}), + ("get", "/apps/{app_id}/webapp-access/agent"): frozenset({"200", "default"}), + ("put", "/apps/{app_id}/webapp-access/agent"): frozenset({"200", "422", "default"}), } _HTTP_METHODS = frozenset({"get", "post", "put", "patch", "delete", "head", "options", "trace"}) diff --git a/api/tests/unit_tests/controllers/openapi/test_catalog.py b/api/tests/unit_tests/controllers/openapi/test_catalog.py index 87551838cb4..fa2ed38db13 100644 --- a/api/tests/unit_tests/controllers/openapi/test_catalog.py +++ b/api/tests/unit_tests/controllers/openapi/test_catalog.py @@ -93,7 +93,8 @@ def test_every_guarded_route_declares_catalog_meta_once(app: Flask, ops: dict[st assert meta.kind is Kind.SSE, meta.op assert set(ops) <= set(seen) per_mode = {f"run.console_app.{mode}" for mode in ("workflow", "chat", "advanced_chat", "completion")} - assert streaming == per_mode | {"get.run.event"} + draft = {f"test.console_app.{mode}" for mode in ("workflow", "advanced_chat")} + assert streaming == per_mode | draft | {"get.run.event"} def test_run_entries_carry_path_bind_kind_and_flags(ops: dict[str, CatalogOp]) -> None: @@ -104,7 +105,7 @@ def test_run_entries_carry_path_bind_kind_and_flags(ops: dict[str, CatalogOp]) - "/openapi/v1/apps/{app_id}/chat:run", "sse", ) - assert {"app_id", "inputs", "query"} <= set(chat["input"]["required"]) + assert {"app_id", "query"} <= set(chat["input"]["required"]) assert set(chat["bind"]) == set(chat["input"]["properties"]) assert {k: chat["bind"][k] for k in ("app_id", "inputs", "files", "attachments")} == { "app_id": "path", @@ -116,7 +117,6 @@ def test_run_entries_carry_path_bind_kind_and_flags(ops: dict[str, CatalogOp]) - assert ops["upload.console_app.file"]["bind"]["file"] == "file" assert ops["get.console_app"]["bind"]["page"] == "query" assert ops["get.run.event"]["bind"]["continue_on_pause"] == "query" - assert ops["switch.workspace"]["internal"] is True def test_input_schemas_are_flat_shallow_and_described(ops: dict[str, CatalogOp]) -> None: diff --git a/api/tests/unit_tests/controllers/openapi/test_contract.py b/api/tests/unit_tests/controllers/openapi/test_contract.py index 3f731062f7f..fc8d473edc7 100644 --- a/api/tests/unit_tests/controllers/openapi/test_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_contract.py @@ -77,6 +77,31 @@ def test_paginated_fills_the_next_page_hint_from_the_call_and_leaves_the_rest_al assert plain(ctx=object()) == ContractResp(value=1) +def test_paginated_keeps_the_view_hints_and_adds_the_next_page() -> None: + setup = Hint(summary="Set up", op="thing.describe", input={}) + + @paginated("thing.list") + def view(*, ctx: object, query: ContractQuery) -> _Page: + page = _Page.build(page=query.page, limit=query.limit, total=50, items=[]) + page.hints = [setup] + return page + + assert [h.op for h in view(ctx=object(), query=ContractQuery(page=1, limit=20)).hints] == [ + "thing.describe", + "thing.list", + ] + + own = Hint(summary="Next page", op="thing.list", input={"cursor": "c2"}) + + @paginated("thing.list") + def cursor_view(*, ctx: object, query: ContractQuery) -> _Page: + page = _Page.build(page=query.page, limit=query.limit, total=50, items=[]) + page.hints = [own] + return page + + assert cursor_view(ctx=object(), query=ContractQuery(page=1, limit=20)).hints == [own] + + def test_accepts_injects_validated_query_with_defaults_for_absent_fields(app): @accepts(query=ContractQuery) def view(*, query): diff --git a/api/tests/unit_tests/controllers/openapi/test_error_contract.py b/api/tests/unit_tests/controllers/openapi/test_error_contract.py index 28e7541733b..19f9e8eff98 100644 --- a/api/tests/unit_tests/controllers/openapi/test_error_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_error_contract.py @@ -29,6 +29,7 @@ from controllers.common.errors import ( from controllers.openapi._catalog import CATALOG_HEADER, catalog_for from controllers.openapi._errors import ( CatalogStale, + DslInvalid, ErrorBody, ErrorDetail, FilenameNotExists, @@ -40,6 +41,7 @@ from controllers.openapi._errors import ( OpenApiErrorFormatter, RecipientSurfaceMismatch, ) +from controllers.openapi._models import Hint from controllers.service_api.app.error import ( AgentNotPublishedError, AppUnavailableError, @@ -338,3 +340,21 @@ class TestErrorCodeEnumRegistration: schema = model.__schema__ assert schema["type"] == "string" assert set(schema["enum"]) == {member.value for member in OpenApiErrorCode} + + +def test_error_body_carries_hints() -> None: + hint = Hint(summary="Check the DSL", op="check.console_app.dsl", input={}) + error = DslInvalid(hints=[hint]) + assert error.hints == [hint] + body = ErrorBody(code="dsl_invalid", message="m", status=422, hints=[hint]) + assert body.model_dump(exclude_none=True)["hints"][0]["op"] == "check.console_app.dsl" + + +def test_formatter_emits_hints_only_from_openapi_errors() -> None: + hint = Hint(summary="Check the DSL", op="check.console_app.dsl", input={"yaml_content": None}) + wire = OpenApiErrorFormatter().finalize(DslInvalid(hints=[hint]), {}, 422) + assert (wire["code"], wire["hints"]) == ("dsl_invalid", [hint.model_dump(mode="json", exclude_none=True)]) + + foreign = BadRequest() + foreign.hints = [hint] # type: ignore[attr-defined] + assert "hints" not in OpenApiErrorFormatter().finalize(foreign, {}, 400) diff --git a/api/tests/unit_tests/controllers/openapi/test_hints.py b/api/tests/unit_tests/controllers/openapi/test_hints.py index a03fd712fd5..15bf6667ff6 100644 --- a/api/tests/unit_tests/controllers/openapi/test_hints.py +++ b/api/tests/unit_tests/controllers/openapi/test_hints.py @@ -5,12 +5,8 @@ from collections.abc import Iterator, Mapping from pydantic import BaseModel -from controllers.openapi._hints import attach_stream_hints, next_page_hint -from controllers.openapi._models import Hint, PageQuery, PaginationEnvelope - - -class _Envelope(PaginationEnvelope[str]): - pass +from controllers.openapi._hints import attach_stream_hints, next_page_hint, page_after +from controllers.openapi._models import Hint, PageQuery class _Query(PageQuery): @@ -18,17 +14,17 @@ class _Query(PageQuery): def test_next_page_hint_copies_path_and_query_and_bumps_page() -> None: - hint = next_page_hint( + [hint] = next_page_hint( op="thing.list", path_args={"workspace_id": "ws-1"}, query=_Query(page=2, name="x"), - envelope=_Envelope.build(page=2, limit=20, total=100, items=[]), + page=page_after(page=2, limit=20, has_more=True), ) assert hint == Hint( summary="Next page", op="thing.list", input={"workspace_id": "ws-1", "name": "x", "page": 3, "limit": 20} ) - last = _Envelope.build(page=5, limit=20, total=100, items=[]) - assert next_page_hint(op="thing.list", path_args={}, query=None, envelope=last) is None + last = page_after(page=5, limit=20, has_more=False) + assert next_page_hint(op="thing.list", path_args={}, query=None, page=last) == [] def _sse(event: Mapping[str, object]) -> str: diff --git a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py index a882b9dcbae..a81cc621809 100644 --- a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py @@ -20,7 +20,10 @@ from unittest.mock import Mock import pytest from flask import Flask +from jsonschema import Draft202012Validator +from controllers.openapi import bp as openapi_bp +from controllers.openapi._catalog import build_catalog from controllers.openapi._contract import op_of from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch from controllers.openapi._models import FormSubmitResponse, OpenApiFormSubmitPayload @@ -33,6 +36,7 @@ from controllers.openapi.human_input_form import ( OpenApiWorkflowHumanInputFormSubmitApi, with_form_hints, ) +from controllers.openapi.workflow_events import OpenApiWorkflowEventsApi from core.app.entities.task_entities import HumanInputRequiredResponse from core.workflow.nodes.human_input import ParagraphInputConfig, UserActionConfig from models.account import Account @@ -72,13 +76,19 @@ def _mock_service(monkeypatch: pytest.MonkeyPatch, form) -> Mock: return service_mock -def _make_form(app_id: str = "app-1", recipient_type=RecipientType.STANDALONE_WEB_APP) -> SimpleNamespace: - return SimpleNamespace( +def _make_form( + app_id: str = "app-1", + recipient_type: RecipientType = RecipientType.STANDALONE_WEB_APP, + workflow_run_id: str | None = None, +) -> SimpleNamespace: + form = SimpleNamespace( app_id=app_id, tenant_id="tenant-1", recipient_type=recipient_type, expiration_time=datetime(2099, 1, 1, tzinfo=UTC), ) + form.workflow_run_id = workflow_run_id + return form def _make_account(account_id: str = "acct-1") -> Account: @@ -209,6 +219,46 @@ class TestOpenApiHumanInputFormPost: assert result == FormSubmitResponse() +class TestSubmitFollowRunHint: + def _submit( + self, app: Flask, monkeypatch: pytest.MonkeyPatch, *, workflow_run_id: str | None + ) -> FormSubmitResponse: + _mock_service(monkeypatch, _make_form(workflow_run_id=workflow_run_id)) + api = OpenApiWorkflowHumanInputFormSubmitApi() + with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1:submit", method="POST"): + return api.post.__handler__( + api, + _context(_make_account(), CreatorUserRole.ACCOUNT), + app_id="app-1", + form_token="tok-1", + body=OpenApiFormSubmitPayload(action="approve", inputs={}), + ) + + def test_hints_following_the_run_the_form_paused(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + result = self._submit(app, monkeypatch, workflow_run_id="run-9") + + [hint] = result.hints + assert hint.op == op_of(OpenApiWorkflowEventsApi.get) == "get.run.event" + assert hint.input == { + "app_id": "app-1", + "task_id": "run-9", + "include_state_snapshot": True, + "continue_on_pause": False, + } + + def test_hint_input_passes_the_events_op_input_schema(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + [hint] = self._submit(app, monkeypatch, workflow_run_id="run-9").hints + + catalog_app = Flask(__name__) + catalog_app.register_blueprint(openapi_bp) + schema = build_catalog(catalog_app)["ops"][hint.op]["input"] + validator = Draft202012Validator({**schema, "additionalProperties": False}) + assert [err.message for err in validator.iter_errors(hint.input)] == [] + + def test_no_hint_without_a_run(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + assert self._submit(app, monkeypatch, workflow_run_id=None).hints == [] + + @pytest.mark.parametrize( "view", [OpenApiWorkflowHumanInputFormApi.get, OpenApiWorkflowHumanInputFormSubmitApi.post], diff --git a/api/tests/unit_tests/controllers/openapi/test_knowledge_bases.py b/api/tests/unit_tests/controllers/openapi/test_knowledge_bases.py new file mode 100644 index 00000000000..5f55709de63 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_knowledge_bases.py @@ -0,0 +1,28 @@ +from controllers.openapi._models import KNOWLEDGE_TOP_K +from controllers.openapi.knowledge_bases import knowledge_base_row + + +def _record(**extra: object) -> dict[str, object]: + return { + "id": "ds-1", + "name": "Docs", + "description": None, + "provider": "vendor", + "indexing_technique": "high_quality", + "document_count": 3, + "embedding_available": True, + **extra, + } + + +def test_row_carries_a_retrieval_fragment() -> None: + row = knowledge_base_row(_record()) + assert row.node_data.dataset_ids == ["ds-1"] + assert row.node_data.retrieval_mode == "multiple" + assert row.node_data.multiple_retrieval_config.top_k == KNOWLEDGE_TOP_K + assert row.node_data.multiple_retrieval_config.reranking_enable is False + + +def test_unavailable_embedding_is_reported() -> None: + assert knowledge_base_row(_record(embedding_available=False)).usable is False + assert knowledge_base_row(_record(embedding_available=None, provider="external")).usable is True diff --git a/api/tests/unit_tests/controllers/openapi/test_model_providers.py b/api/tests/unit_tests/controllers/openapi/test_model_providers.py new file mode 100644 index 00000000000..8202aaf9ddb --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_model_providers.py @@ -0,0 +1,312 @@ +import uuid +from collections.abc import Callable +from typing import Protocol, cast + +import pytest +from flask import Flask +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from constants.oauth_bearer import TokenType +from controllers.openapi import model_providers +from controllers.openapi._models import ( + CredentialWriteResponse, + ModelCredentialCreatePayload, + ModelListQuery, + ModelProviderDetailResponse, + ModelRef, +) +from controllers.openapi._search import matches +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.subjects import AccountSubject +from controllers.openapi.model_providers import ( + ModelCredentialsApi, + ModelProviderApi, + _model_write_response, + _provider_write_response, + model_rows, +) +from core.entities.model_entities import ModelStatus, ModelWithProviderEntity, SimpleModelProviderEntity +from core.entities.provider_entities import CredentialConfiguration, CustomModelConfiguration +from extensions.ext_application_services import ApplicationServices +from graphon.model_runtime.entities.common_entities import I18nObject +from graphon.model_runtime.entities.model_entities import FetchFrom, ModelPropertyKey, ModelType +from graphon.model_runtime.entities.provider_entities import ( + ConfigurateMethod, + CredentialFormSchema, + FormType, + ProviderCredentialSchema, +) +from libs.oauth_bearer import AuthContext +from services.entities.model_provider_entities import ( + CustomConfigurationResponse, + CustomConfigurationStatus, + ProviderResponse, + ProviderType, + SystemConfigurationResponse, +) +from services.model_provider.service import ModelProviderService +from tests.unit_tests.model_factories import make_account, make_tenant + + +def _model( + name: str, + *, + provider: str = "langgenius/openai/openai", + deprecated: bool = False, + model_type: ModelType = ModelType.LLM, + model_properties: dict[ModelPropertyKey, object] | None = None, +) -> ModelWithProviderEntity: + data: dict[str, object] = { + "model": name, + "label": {"en_US": name.upper()}, + "model_type": model_type, + "features": [], + "fetch_from": FetchFrom.PREDEFINED_MODEL, + "model_properties": model_properties or {}, + "deprecated": deprecated, + "status": ModelStatus.ACTIVE, + "load_balancing_enabled": False, + "has_invalid_load_balancing_configs": False, + } + # The entity's __init__ takes a ProviderEntity; build the short form directly. + simple = SimpleModelProviderEntity.model_construct( + provider=provider, label=I18nObject(en_US="OpenAI"), supported_model_types=[ModelType.LLM] + ) + return ModelWithProviderEntity.model_validate({**data, "provider": simple}) + + +def test_rows_carry_their_provider() -> None: + [row] = model_rows([_model("gpt-4o")], words="", language=None) + assert (row.provider, row.provider_label, row.model, row.label) == ( + "langgenius/openai/openai", + "OpenAI", + "gpt-4o", + "GPT-4O", + ) + + +def test_llm_rows_carry_the_node_model_block() -> None: + model = _model("gpt-4o", model_properties={ModelPropertyKey.MODE: "chat"}) + [row] = model_rows([model], words="", language=None) + assert row.node_model is not None + assert row.node_model.model_dump() == { + "provider": "langgenius/openai/openai", + "name": "gpt-4o", + "mode": "chat", + "completion_params": {}, + } + + +def test_rows_without_a_node_model_when_not_llm_or_no_mode() -> None: + embedding = _model("embed", model_type=ModelType.TEXT_EMBEDDING, model_properties={ModelPropertyKey.MODE: "chat"}) + no_mode = _model("plain") + assert [r.node_model for r in model_rows([embedding, no_mode], words="", language=None)] == [None, None] + + +def test_deprecated_models_are_left_out() -> None: + rows = model_rows([_model("gpt-4o"), _model("gpt-3", deprecated=True)], words="", language=None) + assert [r.model for r in rows] == ["gpt-4o"] + + +def test_query_matches_model_label_or_provider() -> None: + models = [_model("gpt-4o"), _model("claude", provider="langgenius/anthropic/anthropic")] + assert [r.model for r in model_rows(models, words="anthropic", language=None)] == ["claude"] + assert [r.model for r in model_rows(models, words="GPT", language=None)] == ["gpt-4o"] + + +def test_unknown_model_type_is_rejected() -> None: + with pytest.raises(ValidationError): + ModelListQuery.model_validate({"model_type": "video"}) + + +def test_matches_ignores_case_and_none() -> None: + assert matches("Git", None, "github") + assert matches("", None) + assert not matches("slack", "github", None) + + +def _provider_response( + provider: str, + configuration: CustomConfigurationResponse, + schema: ProviderCredentialSchema | None = None, +) -> ProviderResponse: + return ProviderResponse( + tenant_id="ws", + provider=provider, + label=I18nObject(en_US=provider), + supported_model_types=[ModelType.LLM], + configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], + provider_credential_schema=schema, + preferred_provider_type=ProviderType.CUSTOM, + custom_configuration=configuration, + system_configuration=SystemConfigurationResponse(enabled=False), + ) + + +def _context(session: Session) -> Context: + subject = AccountSubject( + AuthContext( + subject_email="caller@example.com", + subject_issuer="dify:account", + account_id=uuid.uuid4(), + client_id="difyctl", + token_id=uuid.uuid4(), + token_type=TokenType.OAUTH_ACCOUNT, + expires_at=None, + ) + ) + ctx = Context(subject, session, {"workspace_id": "ws"}) + ctx._workspace = make_tenant(tenant_id="ws") + ctx._caller = make_account(interface_language="en-US") + return ctx + + +def test_inactive_credential_hints_replace_active() -> None: + configuration = CustomConfigurationResponse( + status=CustomConfigurationStatus.ACTIVE, + current_credential_id="active-id", + available_credentials=[ + CredentialConfiguration(credential_id="active-id", credential_name="main"), + CredentialConfiguration(credential_id="new-id", credential_name="spare"), + ], + ) + provider = _provider_response("langgenius/openai/openai", configuration) + response = _provider_write_response("ws", provider, "new-id") + [hint] = response.hints + assert not response.active + assert hint.op == "set.model_provider.credential" + assert hint.input["credential_id"] == "active-id" + + +class _CreateView(Protocol): + __handler__: Callable[..., tuple[CredentialWriteResponse, int]] + + +def _model_provider(*custom_models: CustomModelConfiguration) -> ProviderResponse: + configuration = CustomConfigurationResponse( + status=CustomConfigurationStatus.ACTIVE, custom_models=list(custom_models) + ) + return _provider_response(_OLLAMA, configuration) + + +def _custom(current: str | None) -> CustomModelConfiguration: + return CustomModelConfiguration( + model="llama3", + model_type=ModelType.LLM, + credentials=None, + current_credential_id=current, + available_model_credentials=[ + CredentialConfiguration(credential_id="new-id", credential_name="spare"), + CredentialConfiguration(credential_id="other-id", credential_name="main"), + ], + ) + + +_LLAMA = ModelRef(model="llama3", model_type=ModelType.LLM) +_OLLAMA = "langgenius/ollama/ollama" + + +def test_a_new_models_first_credential_reads_back_active() -> None: + response = _model_write_response("ws", _model_provider(_custom("new-id")), _LLAMA, "new-id") + assert (response.active, response.name, response.hints) == (True, "spare", []) + + +def test_a_credential_beside_an_active_one_hints_replacing_the_active_one() -> None: + response = _model_write_response("ws", _model_provider(_custom("other-id")), _LLAMA, "new-id") + [hint] = response.hints + assert not response.active + assert hint.op == "set.model.credential" + assert hint.input["credential_id"] == "other-id" + + +@pytest.mark.parametrize( + "provider", [_model_provider(_custom(None)), _model_provider()], ids=["none-active", "unknown"] +) +def test_nothing_active_reads_back_inactive_without_a_hint(provider: ProviderResponse) -> None: + response = _model_write_response("ws", provider, _LLAMA, "new-id") + assert (response.active, response.hints) == (False, []) + + +def test_creating_a_model_credential_never_switches_the_active_one( + app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + writes: list[str] = [] + + def create_model_credential(_self: ModelProviderService, **_kwargs: object) -> str: + writes.append("create_model_credential") + return "new-id" + + def recorded(name: str) -> Callable[..., None]: + return lambda *_args, **_kwargs: writes.append(name) + + monkeypatch.setattr(ModelProviderService, "create_model_credential", create_model_credential) + for switch in ( + "switch_active_custom_model_credential", + "switch_active_provider_credential", + "add_model_credential_to_model_list", + ): + monkeypatch.setattr(ModelProviderService, switch, recorded(switch)) + monkeypatch.setattr(model_providers, "_provider", lambda *_args: _model_provider(_custom("other-id"))) + body = ModelCredentialCreatePayload(model="llama3", model_type=ModelType.LLM, credentials={"base_url": "x"}) + + api = ModelCredentialsApi() + with app.test_request_context("/openapi/v1/workspaces/ws/model-providers/x/models/credentials", method="POST"): + response, status = cast(_CreateView, api.post).__handler__( + api, _context(sqlite_session), "ws", _OLLAMA, body=body + ) + + assert status == 201 + assert not response.active + assert writes == ["create_model_credential"] + + +class _DescribeView(Protocol): + __handler__: Callable[..., ModelProviderDetailResponse] + + +def _describable_provider(status: CustomConfigurationStatus) -> ProviderResponse: + schema = ProviderCredentialSchema( + credential_form_schemas=[ + CredentialFormSchema(variable="api_key", label=I18nObject(en_US="API Key"), type=FormType.SECRET_INPUT) + ] + ) + return _provider_response(_OPENAI, CustomConfigurationResponse(status=status), schema) + + +_OPENAI = "langgenius/openai/openai" +_ASK_SUMMARY = "Ask the user for these values, then save them" + + +@pytest.mark.parametrize( + ("status", "visible", "asks"), + [ + (CustomConfigurationStatus.ACTIVE, [], False), + ( + CustomConfigurationStatus.ACTIVE, + [CredentialConfiguration(credential_id="c1", credential_name="main")], + False, + ), + (CustomConfigurationStatus.NO_CONFIGURE, [], True), + ], + ids=["active-via-a-credential-the-caller-cannot-see", "active-and-visible", "not-configured"], +) +def test_describe_asks_for_credentials_only_while_the_provider_is_not_configured( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + account_application_services: ApplicationServices, + status: CustomConfigurationStatus, + visible: list[CredentialConfiguration], + asks: bool, +) -> None: + monkeypatch.setattr(ModelProviderService, "get_provider_available_credentials", lambda *_args, **_kwargs: visible) + monkeypatch.setattr(model_providers, "_provider", lambda *_args: _describable_provider(status)) + monkeypatch.setattr(model_providers, "application_services", lambda: account_application_services) + + api = ModelProviderApi() + with app.test_request_context(f"/openapi/v1/workspaces/ws/model-providers/{_OPENAI}"): + response = cast(_DescribeView, api.get).__handler__(api, _context(sqlite_session), "ws", _OPENAI) + + assert (_ASK_SUMMARY in [hint.summary for hint in response.hints]) is asks + assert response.configured is (status == CustomConfigurationStatus.ACTIVE) diff --git a/api/tests/unit_tests/controllers/openapi/test_models.py b/api/tests/unit_tests/controllers/openapi/test_models.py index 9dd7e5b69fc..611cb2e7ab9 100644 --- a/api/tests/unit_tests/controllers/openapi/test_models.py +++ b/api/tests/unit_tests/controllers/openapi/test_models.py @@ -3,4 +3,4 @@ from controllers.openapi._models import AppDescribeResponse def test_describe_response_all_blocks_optional() -> None: payload = AppDescribeResponse().model_dump(mode="json", exclude_none=False) - assert payload == {"info": None, "parameters": None, "input_schema": None} + assert payload == {"info": None, "parameters": None, "input_schema": None, "hints": []} diff --git a/api/tests/unit_tests/controllers/openapi/test_node_types.py b/api/tests/unit_tests/controllers/openapi/test_node_types.py new file mode 100644 index 00000000000..88a32512f03 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_node_types.py @@ -0,0 +1,82 @@ +import uuid +from collections.abc import Callable, Mapping +from typing import Protocol, cast + +import pytest +from flask import Flask +from sqlalchemy.orm import Session, sessionmaker + +from constants.oauth_bearer import TokenType +from controllers.openapi import node_types +from controllers.openapi._models import NodeTypeDetailResponse +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.subjects import AccountSubject +from graphon.enums import BuiltinNodeTypes +from graphon.nodes.code import CodeNode +from graphon.nodes.http_request import HttpRequestNode +from libs.oauth_bearer import AuthContext +from services.workflow_service import WorkflowService + + +class _EndpointView(Protocol): + __handler__: Callable[..., object] + + +@pytest.fixture +def describe( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> Callable[[str], NodeTypeDetailResponse]: + # `WorkflowService()` builds its sessions from db.engine, which the bare Flask app does not have + monkeypatch.setattr(node_types, "WorkflowService", lambda: WorkflowService(sqlite_session_factory)) + auth = AuthContext( + token_type=TokenType.OAUTH_ACCOUNT, + subject_email="caller@example.com", + subject_issuer="dify:account", + account_id=uuid.uuid4(), + client_id="difyctl", + token_id=uuid.uuid4(), + expires_at=None, + ) + + def run(node_type: str) -> NodeTypeDetailResponse: + ctx = Context(AccountSubject(auth), sqlite_session, {"node_type": node_type}) + api = node_types.NodeTypeDetailApi() + with app.test_request_context(f"/openapi/v1/node-types/{node_type}"): + return cast(NodeTypeDetailResponse, cast(_EndpointView, api.get).__handler__(api, ctx, node_type)) + + return run + + +def test_http_request_default_body_carries_an_empty_data_list( + describe: Callable[[str], NodeTypeDetailResponse], +) -> None: + graphon_default = HttpRequestNode.get_default_config() + graphon_config = cast(Mapping[str, object], graphon_default["config"]) + assert "data" not in cast(Mapping[str, object], graphon_config["body"]) + + response = describe(BuiltinNodeTypes.HTTP_REQUEST) + + assert response.default_config["config"]["body"] == {"type": "none", "data": []} + assert response.default_config["config"]["method"] == graphon_config["method"] + assert response.default_config["retry_config"] == graphon_default["retry_config"] + + +def test_keys_the_default_already_has_are_kept(describe: Callable[[str], NodeTypeDetailResponse]) -> None: + graphon_default = HttpRequestNode.get_default_config() + graphon_timeout = cast(Mapping[str, object], cast(Mapping[str, object], graphon_default["config"])["timeout"]) + + response = describe(BuiltinNodeTypes.HTTP_REQUEST) + + timeout = response.default_config["config"]["timeout"] + assert {key: timeout[key] for key in graphon_timeout} == graphon_timeout + + +def test_sub_objects_that_fail_their_model_pass_through(describe: Callable[[str], NodeTypeDetailResponse]) -> None: + graphon_default = CodeNode.get_default_config() + + response = describe(BuiltinNodeTypes.CODE) + + assert response.default_config == dict(graphon_default) diff --git a/api/tests/unit_tests/controllers/openapi/test_plugins.py b/api/tests/unit_tests/controllers/openapi/test_plugins.py new file mode 100644 index 00000000000..ee817b9d97e --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_plugins.py @@ -0,0 +1,11 @@ +import httpx +import pytest + +from controllers.openapi._errors import MarketplaceUnavailable +from controllers.openapi.plugins import _plugin_errors + + +def test_marketplace_network_failure_is_marketplace_unavailable() -> None: + with pytest.raises(MarketplaceUnavailable): + with _plugin_errors(): + raise httpx.ConnectError("marketplace unreachable") diff --git a/api/tests/unit_tests/controllers/openapi/test_tool_providers.py b/api/tests/unit_tests/controllers/openapi/test_tool_providers.py new file mode 100644 index 00000000000..59a7596703d --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_tool_providers.py @@ -0,0 +1,159 @@ +import pytest +from pydantic import ValidationError +from werkzeug.exceptions import BadRequest + +from controllers.openapi._models import PageQuery, ToolListQuery, ToolListResponse +from controllers.openapi.tool_providers import _credential_write_errors, tool_rows +from core.tools.__base.tool import ToolParameter +from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity +from core.tools.entities.common_entities import I18nObject +from core.tools.entities.tool_entities import ToolProviderType + + +@pytest.mark.parametrize( + "message", + [ + "the credential name 'main' is already used", + "you have reached the maximum number of providers for tavily", + "provider tavily does not need credentials", + ], +) +def test_other_failures_are_bad_request_with_the_service_message(message: str) -> None: + with pytest.raises(BadRequest, match=message): + with _credential_write_errors(): + raise ValueError(message) + + +Form = ToolParameter.ToolParameterForm +ParamType = ToolParameter.ToolParameterType + + +def _param( + name: str, form: ToolParameter.ToolParameterForm, *, default: str | None = None, required: bool = False +) -> ToolParameter: + return ToolParameter( + name=name, label=I18nObject(en_US=name), type=ParamType.STRING, form=form, default=default, required=required + ) + + +def _tool(name: str, parameters: list[ToolParameter] | None, label: str | None = None) -> ToolApiEntity: + return ToolApiEntity( + author="a", + name=name, + label=I18nObject(en_US=label if label is not None else name.title()), + description=I18nObject(en_US=f"{name} tool"), + parameters=parameters, + ) + + +def _provider( + pid: str, + kind: ToolProviderType, + tools: list[ToolApiEntity], + *, + plugin_id: str = "", + plugin_unique_identifier: str = "", + server_identifier: str = "", + is_team_authorization: bool = True, +) -> ToolProviderApiEntity: + return ToolProviderApiEntity( + id=pid, + author="a", + name=pid, + description=I18nObject(en_US=pid), + icon="", + label=I18nObject(en_US=pid.split("/")[-1].title()), + type=kind, + tools=tools, + plugin_id=plugin_id, + plugin_unique_identifier=plugin_unique_identifier, + server_identifier=server_identifier, + is_team_authorization=is_team_authorization, + ) + + +GITHUB = _provider( + "langgenius/github/github", + ToolProviderType.BUILT_IN, + [ + _tool( + "get_issues", + [ + _param("repo", Form.LLM, required=True), + _param("state", Form.FORM, default="open"), + _param("spec", Form.SCHEMA), + ], + ) + ], + plugin_id="langgenius/github", + plugin_unique_identifier="langgenius/github:0.1.0@abc", + is_team_authorization=False, +) + + +def test_parameters_split_by_form() -> None: + [row] = tool_rows([GITHUB], words="", provider=None, language=None) + data = row.node_data + assert {k: (v.type, v.value) for k, v in data.tool_parameters.items()} == {"repo": ("constant", None)} + assert {k: (v.type, v.value) for k, v in data.tool_configurations.items()} == {"state": ("constant", "open")} + assert [p.name for p in row.parameters] == ["repo", "state", "spec"] + assert data.tool_node_version == "2" + assert (data.plugin_id, data.plugin_unique_identifier) == ("langgenius/github", "langgenius/github:0.1.0@abc") + assert (data.provider_id, data.provider_name, data.provider_type) == ( + "langgenius/github/github", + "langgenius/github/github", + "builtin", + ) + assert row.configured is False + + +def test_mcp_provider_id_is_the_server_identifier_and_no_plugin_fields() -> None: + mcp = _provider("uuid-1", ToolProviderType.MCP, [_tool("search", [])], server_identifier="my-server") + [row] = tool_rows([mcp], words="", provider=None, language=None) + assert row.node_data.provider_id == "my-server" + assert row.node_data.plugin_id is None + assert row.node_data.plugin_unique_identifier is None + + +def test_tool_without_parameters() -> None: + wf = _provider("wf-uuid", ToolProviderType.WORKFLOW, [_tool("summarize", None)]) + [row] = tool_rows([wf], words="", provider=None, language=None) + assert row.parameters == [] + assert row.node_data.tool_parameters == {} + assert row.node_data.tool_configurations == {} + assert row.node_data.tool_name == "summarize" + + +def test_label_falls_back_to_name() -> None: + p = _provider("x/y/z", ToolProviderType.API, [_tool("ping", [], label="")]) + [row] = tool_rows([p], words="", provider=None, language=None) + assert row.label == "ping" + assert row.node_data.title == "ping" + assert row.node_data.tool_label == "ping" + + +def test_rows_are_flattened_sorted_and_filtered() -> None: + other = _provider("acme/slack/slack", ToolProviderType.BUILT_IN, [_tool("send", []), _tool("list", [])]) + rows = tool_rows([GITHUB, other], words="", provider=None, language=None) + assert [(r.provider, r.name) for r in rows] == [ + ("acme/slack/slack", "list"), + ("acme/slack/slack", "send"), + ("langgenius/github/github", "get_issues"), + ] + assert [r.name for r in tool_rows([GITHUB, other], words="GITHUB", provider=None, language=None)] == ["get_issues"] + assert [r.name for r in tool_rows([GITHUB, other], words="", provider="acme/slack/slack", language=None)] == [ + "list", + "send", + ] + assert tool_rows([GITHUB, other], words="", provider="no/such/provider", language=None) == [] + + +def test_page_past_the_end() -> None: + rows = tool_rows([GITHUB], words="", provider=None, language=None) + page = ToolListResponse.page_of(rows, query=PageQuery(page=99, limit=20)) + assert (page.data, page.total, page.has_more) == ([], 1, False) + + +def test_unknown_provider_type_is_rejected() -> None: + with pytest.raises(ValidationError): + ToolListQuery.model_validate({"provider_type": "plugin"}) diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py index c0e1c2f757f..e4a58e2b1c3 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -45,7 +45,7 @@ from controllers.openapi._models import MemberInvitePayload, MemberListQuery, Me from controllers.openapi.auth.context import Context from controllers.openapi.auth.loaders import load_caller, load_workspace from controllers.openapi.auth.subjects import AccountSubject -from controllers.openapi.workspaces import WorkspaceMemberApi, WorkspaceMembersApi, WorkspaceSwitchApi +from controllers.openapi.workspaces import WorkspaceMemberApi, WorkspaceMembersApi from libs.oauth_bearer import AuthContext from models import Account, Tenant, TenantAccountJoin from models.account import TenantAccountRole @@ -59,7 +59,6 @@ from services.errors.workspace import ( RoleAlreadyAssignedError, WorkspaceInvitationQuotaError, WorkspaceMemberLicenseQuotaError, - WorkspaceNotLinkedError, ) from tests.unit_tests.account_domain import AccountDomain from tests.unit_tests.controllers.openapi.conftest import AdmittedWorld @@ -221,64 +220,6 @@ def test_invalid_request_is_422_on_the_wire( _assert_validation_422(resp) -# --------------------------------------------------------------------------- -# Switch endpoint behavior -# --------------------------------------------------------------------------- - - -def test_switch_returns_workspace_detail_with_current_true(database_session: Session): - """Happy path: switch service is called, then the workspace+membership - row is re-queried so the returned `current` reflects post-commit state. - """ - ws_id = str(uuid.uuid4()) - acct_id = uuid.uuid4() - api = WorkspaceSwitchApi() - - _persist_workspace( - database_session, - ws_id, - [(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, False)], - ) - - result = api.post.__handler__(api, _context(database_session, acct_id, ws_id), workspace_id=ws_id) - - assert result.id == ws_id - assert result.current is True - membership = database_session.scalar( - select(TenantAccountJoin).where( - TenantAccountJoin.tenant_id == ws_id, - TenantAccountJoin.account_id == str(acct_id), - ) - ) - assert membership is not None - assert membership.current is True - - -def test_switch_404s_when_service_raises_account_not_link_tenant( - monkeypatch: pytest.MonkeyPatch, database_session: Session -): - """If switch_tenant raises (e.g. Tenant.status != NORMAL), the body - surfaces as NotFound, not 500.""" - ws_id = str(uuid.uuid4()) - acct_id = uuid.uuid4() - api = WorkspaceSwitchApi() - - _persist_workspace( - database_session, - ws_id, - [(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, False)], - ) - - monkeypatch.setattr( - workspaces_module.application_services().workspaces.management, - "switch_membership", - Mock(side_effect=WorkspaceNotLinkedError("…")), - ) - - with pytest.raises(NotFound): - api.post.__handler__(api, _context(database_session, acct_id, ws_id), workspace_id=ws_id) - - # --------------------------------------------------------------------------- # Members list # --------------------------------------------------------------------------- diff --git a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py index c2adf6589db..078008242f3 100644 --- a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py +++ b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py @@ -929,13 +929,14 @@ def test_create_provider_credential_persists_provider_and_rejects_duplicate( patch.object(ProviderConfiguration, "validate_provider_credentials", return_value={"api_key": "enc"}), _mock_cache_boundaries() as (credentials_cache, configuration_cache), ): - configuration.create_provider_credential({"api_key": "raw"}, "Main") + created_id = configuration.create_provider_credential({"api_key": "raw"}, "Main") credential = sqlite_provider_session.scalar( select(ProviderCredential).where(ProviderCredential.credential_name == "Main") ) provider = sqlite_provider_session.scalar(select(Provider).where(Provider.tenant_id == "tenant-1")) assert credential is not None assert provider is not None + assert created_id == credential.id assert provider.credential_id == credential.id credentials_cache.assert_called_once_with( tenant_id="tenant-1", @@ -1129,11 +1130,12 @@ def test_create_update_and_delete_custom_model_credential(sqlite_provider_sessio patch.object(ProviderConfiguration, "validate_custom_model_credentials", return_value={"api_key": "enc"}), _mock_cache_boundaries() as (credentials_cache, configuration_cache), ): - configuration.create_custom_model_credential(ModelType.LLM, "gpt-4o", {"api_key": "raw"}, "Main") + created_id = configuration.create_custom_model_credential(ModelType.LLM, "gpt-4o", {"api_key": "raw"}, "Main") credential = sqlite_provider_session.scalar(select(ProviderModelCredential)) model = sqlite_provider_session.scalar(select(ProviderModel)) assert credential is not None assert model is not None + assert created_id == credential.id assert model.credential_id == credential.id credential_id = credential.id model_id = model.id diff --git a/api/tests/unit_tests/repositories/app/test_dsl_import_repository.py b/api/tests/unit_tests/repositories/app/test_dsl_import_repository.py new file mode 100644 index 00000000000..4221628ab77 --- /dev/null +++ b/api/tests/unit_tests/repositories/app/test_dsl_import_repository.py @@ -0,0 +1,97 @@ +from collections.abc import Sequence + +import pytest +from sqlalchemy.orm import Session, sessionmaker + +from core.helper import encrypter +from graphon.variables import SecretVariable, StringVariable, VariableBase +from machinery.context import RequestContext +from models.model import App, AppMode +from models.workflow import Workflow, WorkflowType +from repositories.app.dsl_import_repository import DslImportRepository +from services.app.console_service import ConsoleAppNotFoundError +from services.workflow import graph_check +from services.workflow.dsl_import import DslImportTarget +from services.workflow.graph_diff import draft_token +from services.workflow_service import WorkflowService + +_CONTEXT = RequestContext("request-1", None, "account-1", "workspace-1") +_START: dict[str, object] = {"id": "start", "data": {"type": "start", "title": "Start", "variables": []}} + + +@pytest.fixture(autouse=True) +def _identity_secret_encryption(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep secret variables readable without the workspace key provider.""" + monkeypatch.setattr(encrypter, "encrypt_token", lambda **kwargs: kwargs["token"]) + monkeypatch.setattr(encrypter, "decrypt_token", lambda **kwargs: kwargs["token"]) + + +def _add_app(session: Session, *, tenant_id: str = "workspace-1", mode: AppMode = AppMode.WORKFLOW) -> None: + session.add(App(id="app-1", tenant_id=tenant_id, name="a", mode=mode, enable_site=True, enable_api=True)) + session.commit() + + +def _add_draft(session: Session, environment_variables: Sequence[VariableBase] = ()) -> Workflow: + draft = Workflow.new( + tenant_id="workspace-1", + app_id="app-1", + type=WorkflowType.WORKFLOW, + version=Workflow.VERSION_DRAFT, + graph='{"nodes": [], "edges": []}', + features="{}", + created_by="account-1", + environment_variables=environment_variables, + conversation_variables=[], + rag_pipeline_variables=[], + ) + session.add(draft) + session.commit() + return draft + + +def test_load_target_reads_the_draft_token_and_its_secret_ids( + sqlite_session: Session, sqlite_session_factory: sessionmaker[Session] +) -> None: + _add_app(sqlite_session, mode=AppMode.ADVANCED_CHAT) + draft = _add_draft( + sqlite_session, + [ + SecretVariable(id="s-1", name="KEY", value="plain", selector=["env", "KEY"]), + StringVariable(id="v-1", name="NAME", value="x", selector=["env", "NAME"]), + ], + ) + + target = DslImportRepository(session_factory=sqlite_session_factory).load_target(_CONTEXT, "app-1") + + assert target == DslImportTarget( + mode=AppMode.ADVANCED_CHAT, draft_token=draft_token(draft), stored_secret_ids=frozenset({"s-1"}) + ) + + +def test_load_target_without_a_draft_has_no_token_and_no_secrets( + sqlite_session: Session, sqlite_session_factory: sessionmaker[Session] +) -> None: + _add_app(sqlite_session) + + target = DslImportRepository(session_factory=sqlite_session_factory).load_target(_CONTEXT, "app-1") + + assert target == DslImportTarget(mode=AppMode.WORKFLOW, draft_token=None, stored_secret_ids=frozenset()) + + +def test_load_target_refuses_an_app_outside_the_workspace( + sqlite_session: Session, sqlite_session_factory: sessionmaker[Session] +) -> None: + _add_app(sqlite_session, tenant_id="workspace-2") + + with pytest.raises(ConsoleAppNotFoundError): + DslImportRepository(session_factory=sqlite_session_factory).load_target(_CONTEXT, "app-1") + + +def test_credential_check_answers_inside_its_context( + monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session] +) -> None: + # `WorkflowService()` builds its sessions from db.engine; hand it the test database instead. + monkeypatch.setattr(graph_check, "WorkflowService", lambda: WorkflowService(sqlite_session_factory)) + + with DslImportRepository(session_factory=sqlite_session_factory).credential_check("workspace-1", {}) as check: + assert check(_START) is None diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py index 5540960ccb6..3a9a8e2127f 100644 --- a/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py +++ b/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py @@ -4,6 +4,7 @@ import json from datetime import timedelta from typing import cast +import pytest from sqlalchemy.orm import Session, sessionmaker from core.workflow.nodes.human_input.entities import ( @@ -14,7 +15,7 @@ from core.workflow.nodes.human_input.entities import ( from core.workflow.nodes.human_input.enums import HumanInputFormStatus from libs.datetime_utils import naive_utc_now from models.execution_extra_content import HumanInputContent as HumanInputContentModel -from models.human_input import HumanInputForm +from models.human_input import HumanInputForm, HumanInputFormRecipient, RecipientType from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository @@ -108,3 +109,51 @@ def test_map_human_input_content_keeps_waiting_form_without_selected_action() -> assert content.form_definition.node_title == "Approval" assert content.form_definition.form_content == "Rendered Approval" assert content.form_definition.resolved_default_values == {"decision": "approve"} + + +@pytest.mark.parametrize( + ("status", "has_token"), + [ + (HumanInputFormStatus.WAITING, True), + (HumanInputFormStatus.TIMEOUT, False), + (HumanInputFormStatus.EXPIRED, False), + ], +) +def test_map_human_input_content_offers_a_token_only_while_the_form_waits( + status: HumanInputFormStatus, has_token: bool +) -> None: + expiration_time = naive_utc_now() - timedelta(minutes=1) + form_definition = FormDefinition( + form_content="content", + inputs=[], + user_actions=[UserActionConfig(id="approve", title="Approve")], + rendered_content="Rendered Approval", + expiration_time=expiration_time, + node_title="Approval", + display_in_ui=True, + ) + form = HumanInputForm( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="workflow-run-1", + node_id="node-1", + form_definition=form_definition.model_dump_json(), + rendered_content="Rendered Approval", + expiration_time=expiration_time, + status=status, + ) + form.id = "form-1" + model = HumanInputContentModel.new(workflow_run_id="workflow-run-1", form_id=form.id, message_id="message-1") + model.id = "content-1" + model.form = form + recipient = HumanInputFormRecipient( + form_id=form.id, recipient_type=RecipientType.STANDALONE_WEB_APP, access_token="web-token" + ) + repository = SQLAlchemyExecutionExtraContentRepository(cast(sessionmaker[Session], object())) + + content = repository._map_human_input_content(model, {form.id: [recipient]}) + + assert content is not None + assert content.submitted is False + assert content.form_definition is not None + assert content.form_definition.form_token == ("web-token" if has_token else None) diff --git a/api/tests/unit_tests/services/app/test_console_gateway.py b/api/tests/unit_tests/services/app/test_console_gateway.py index 7563389a6a4..2f95b3dcd38 100644 --- a/api/tests/unit_tests/services/app/test_console_gateway.py +++ b/api/tests/unit_tests/services/app/test_console_gateway.py @@ -9,6 +9,7 @@ from zipfile import ZipFile import pytest import yaml +from blinker import Namespace from sqlalchemy import Engine, event, select from sqlalchemy.exc import InvalidRequestError from sqlalchemy.orm import Session, sessionmaker @@ -39,7 +40,8 @@ from services.agent.package_resource_exporter import AgentPackageResourceExporte from services.agent.roster_package_exporter import RosterAgentPackageExporter from services.agent.roster_package_importer import RosterAgentPackageImporter from services.agent.roster_service import AgentRosterService -from services.app.console_gateway import AppTransferGateway, EnterpriseConsoleAppAccess +from services.app import console_gateway +from services.app.console_gateway import AppLifecycleGateway, AppTransferGateway, EnterpriseConsoleAppAccess from services.app.console_service import ConsoleAppNotFoundError, InvalidAppAccessModesError from services.app.export_service import AppExportService from services.app_dsl_service import AppDslService @@ -49,11 +51,19 @@ from services.entities.app_entities import ( AppExportOptions, CopyAppParams, ) -from services.entities.dsl_entities import AppDslExportData, AppImportPackage, AppImportParams, Import, ImportStatus +from services.entities.dsl_entities import ( + AppDslExportData, + AppImportPackage, + AppImportParams, + DraftGuardedImportParams, + Import, + ImportStatus, +) from services.plugin.dependencies_analysis import DependenciesAnalysisService from services.recommended_app_package_service import RecommendedAppPackageService from services.system_feature_service import SystemFeatureService from services.workflow_service import WorkflowService +from tests.unit_tests.audio_runtime_fixtures import record_calls from tests.unit_tests.model_factories import make_account, make_tenant, make_upload_file from tests.unit_tests.rbac_fakes import RBACDomain @@ -712,6 +722,92 @@ def test_console_package_import_forwards_archive_and_closes_it( assert archives[0].archive.closed +@pytest.mark.parametrize( + ("params", "draft_hash"), + [ + (DraftGuardedImportParams(mode="yaml-content", yaml_content="kind: app\n", draft_hash="exported"), "exported"), + (DraftGuardedImportParams(mode="yaml-content", yaml_content="kind: app\n"), None), + (AppImportParams(mode="yaml-content", yaml_content="kind: app\n"), None), + ], + ids=["guarded", "guarded-without-hash", "console"], +) +def test_dsl_import_forwards_only_a_guarded_draft_hash( + app_services: AppServices, + copy_source: tuple[RequestContext, str], + monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], + params: AppImportParams, + draft_hash: str | None, +) -> None: + config_overrides(RBAC_ENABLED=False, DEPLOYMENT_EDITION="COMMUNITY") + context, _ = copy_source + forwarded: list[str | None] = [] + + def import_app(_dsl: AppDslService, *, draft_hash: str | None, **_kwargs: object) -> Import: + forwarded.append(draft_hash) + return Import(id="import-1", status=ImportStatus.FAILED) + + monkeypatch.setattr(AppDslService, "import_app", import_app) + + app_services.console.import_app(context, params) + + assert forwarded == [draft_hash] + + +@pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) +def test_create_draft_saves_the_starter_graph_of_the_apps_mode( + sqlite_session_factory: sessionmaker[Session], + copy_source: tuple[RequestContext, str], + monkeypatch: pytest.MonkeyPatch, + mode: AppMode, +) -> None: + context, app_id = copy_source + with sqlite_session_factory.begin() as session: + app = session.get(App, app_id) + assert app is not None + app.mode = mode + syncs: list[dict[str, object]] = [] + monkeypatch.setattr( + WorkflowService, "sync_draft_workflow", record_calls(WorkflowService.sync_draft_workflow, syncs) + ) + # The app event handlers reach the Flask-bound database; this test only checks the saved draft. + monkeypatch.setattr("services.workflow_service.app_draft_workflow_was_synced", Namespace().signal("synced")) + + AppLifecycleGateway(session_factory=sqlite_session_factory).create_draft(context, app_id) + + assert [sync["graph"] for sync in syncs] == [console_gateway._STARTER_DRAFT_GRAPHS[mode]] + assert syncs[0]["graph"] is not console_gateway._STARTER_DRAFT_GRAPHS[mode] + with sqlite_session_factory() as session: + draft = session.scalar( + select(Workflow).where(Workflow.app_id == app_id, Workflow.version == Workflow.VERSION_DRAFT) + ) + assert draft is not None + assert draft.graph_dict == console_gateway._STARTER_DRAFT_GRAPHS[mode] + + +def test_chatflow_starter_draft_is_start_llm_answer_as_the_editor_saves_it() -> None: + graph = console_gateway._STARTER_DRAFT_GRAPHS[AppMode.ADVANCED_CHAT] + + assert [(n["id"], n["data"]["type"], n["data"]["title"], n["position"]) for n in graph["nodes"]] == [ + ("start", "start", "Start", {"x": 80, "y": 282}), + ("llm", "llm", "LLM", {"x": 380, "y": 282}), + ("answer", "answer", "Answer", {"x": 680, "y": 282}), + ] + assert all( + (n["type"], n["targetPosition"], n["sourcePosition"]) == ("custom", "left", "right") for n in graph["nodes"] + ) + assert [(e["id"], e["source"], e["sourceHandle"], e["target"], e["targetHandle"]) for e in graph["edges"]] == [ + ("start-llm", "start", "source", "llm", "target"), + ("llm-answer", "llm", "source", "answer", "target"), + ] + assert graph["nodes"][2]["data"]["answer"] == "{{#llm.text#}}" + assert json.loads(json.dumps(graph))["nodes"][1]["data"]["model"]["mode"] == "chat" + + +def test_workflow_starter_draft_is_empty() -> None: + assert console_gateway._STARTER_DRAFT_GRAPHS[AppMode.WORKFLOW] == {"nodes": [], "edges": []} + + @pytest.mark.parametrize("commit_fails", [False, True]) @pytest.mark.parametrize("entry", ["import", "confirm", "copy"]) @pytest.mark.parametrize("status", list(ImportStatus)) diff --git a/api/tests/unit_tests/services/app/test_console_service.py b/api/tests/unit_tests/services/app/test_console_service.py index e5e17582ef3..afd0c71be99 100644 --- a/api/tests/unit_tests/services/app/test_console_service.py +++ b/api/tests/unit_tests/services/app/test_console_service.py @@ -477,6 +477,10 @@ class Lifecycle: assert settings.app == {"mode": params.mode} return RECORD + def create_draft(self, context: RequestContext, app_id: str) -> None: + del context, app_id + pytest.fail("Unexpected draft creation") + def prepare_creation(self, context: RequestContext, params: CreateAppParams) -> AppCreationSettings: assert context is CONTEXT return AppCreationSettings({"mode": params.mode}, None) diff --git a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py index 2f478249589..0d9c5a075c4 100644 --- a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py +++ b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py @@ -184,6 +184,15 @@ class TestWebAppAuth: with pytest.raises(ValueError, match="access_mode must be"): EnterpriseService.WebAppAuth.update_app_access_mode("a1", "invalid") + def test_update_access_mode_accepts_sso_verified(self) -> None: + with patch(f"{MODULE}.EnterpriseRequest") as req: + req.send_request.return_value = {"result": True} + EnterpriseService.WebAppAuth.update_app_access_mode("a1", "sso_verified") + + req.send_request.assert_called_once_with( + "POST", "/webapp/access-mode", json={"appId": "a1", "accessMode": "sso_verified"} + ) + def test_update_access_mode_delegates_and_returns(self) -> None: with patch(f"{MODULE}.EnterpriseRequest") as req: req.send_request.return_value = {"result": True} diff --git a/api/tests/unit_tests/services/test_app_dsl_service.py b/api/tests/unit_tests/services/test_app_dsl_service.py index 561dd8602b2..03e91bf174e 100644 --- a/api/tests/unit_tests/services/test_app_dsl_service.py +++ b/api/tests/unit_tests/services/test_app_dsl_service.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from dataclasses import dataclass, field from types import SimpleNamespace from typing import cast from unittest.mock import Mock @@ -10,6 +11,7 @@ from sqlalchemy import event, select from sqlalchemy.orm import Session, sessionmaker from constants.dsl_version import CURRENT_APP_DSL_VERSION +from core.plugin.entities.plugin import PluginDependency, PluginDependencyType from core.rbac import RBACPermission, RBACResourceScope from core.workflow.llm_environment_variable import LLMEnvironmentVariable from models import Account, App, AppMode @@ -20,8 +22,9 @@ from services.agent.dsl_entities import AgentPackage from services.app_dsl_service import AppDslService, PendingData from services.entities.dsl_entities import ImportStatus from services.entities.site_dsl import SiteDsl -from services.errors.app import WorkflowNotFoundError +from services.errors.app import DraftChangedError, WorkflowNotFoundError from services.errors.base import NoPermissionError +from services.workflow.graph_diff import draft_token from tests.unit_tests.config_override import apply_config_overrides from tests.unit_tests.model_factories import make_account, make_app, make_tenant, make_workflow @@ -1057,6 +1060,175 @@ def test_missing_app_section_names_the_keys_that_were_present( assert not unbound_session.in_transaction() +@dataclass +class _DraftWorkflowService: + draft: Workflow | None + lookups: list[str] = field(default_factory=list) + synced_hashes: list[str | None] = field(default_factory=list) + + def _draft_of(self, app_model: App) -> Workflow | None: + return self.draft if self.draft is not None and self.draft.app_id == app_model.id else None + + def get_draft_workflow(self, app_model: App, **_: object) -> Workflow | None: + self.lookups.append("read") + return self._draft_of(app_model) + + def get_draft_workflow_for_update(self, app_model: App, **_: object) -> Workflow | None: + self.lookups.append("locked") + return self._draft_of(app_model) + + def sync_draft_workflow(self, *, graph: dict[str, object], unique_hash: str | None, **_: object) -> Workflow: + self.synced_hashes.append(unique_hash) + return _workflow(graph=graph) + + +def _workflow_draft(app: App) -> Workflow: + return make_workflow(tenant_id=app.tenant_id, app_id=app.id, graph={"nodes": [], "edges": []}) + + +def _guarded_overwrite( + monkeypatch: pytest.MonkeyPatch, session: Session, *, draft: Workflow | None +) -> tuple[AppDslService, _DraftWorkflowService, Callable[[str], App]]: + service = AppDslService(session=session) + workflow_service = _DraftWorkflowService(draft=draft) + monkeypatch.setattr("services.app_dsl_service.WorkflowService", lambda: workflow_service) + app = _app(mode=AppMode.WORKFLOW) + + def overwrite(draft_hash: str) -> App: + return service._create_or_update_app( + app=app, + data={"app": {"mode": AppMode.WORKFLOW.value}, "workflow": {"graph": {"nodes": [], "edges": []}}}, + account=_account(), + draft_hash=draft_hash, + ) + + return service, workflow_service, overwrite + + +def test_guarded_overwrite_saves_against_the_locked_draft_it_matched( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: + draft = _workflow_draft(_app(mode=AppMode.WORKFLOW)) + _, workflow_service, overwrite = _guarded_overwrite(monkeypatch, unbound_session, draft=draft) + + overwrite(draft_token(draft)) + + assert workflow_service.lookups == ["locked"] + assert workflow_service.synced_hashes == [draft.unique_hash] + + +@pytest.mark.parametrize("draft_exists", [True, False], ids=["draft-changed", "draft-gone"]) +def test_guarded_overwrite_refuses_a_draft_that_no_longer_matches( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session, draft_exists: bool +) -> None: + draft = _workflow_draft(_app(mode=AppMode.WORKFLOW)) if draft_exists else None + _, workflow_service, overwrite = _guarded_overwrite(monkeypatch, unbound_session, draft=draft) + + with pytest.raises(DraftChangedError): + overwrite("exported") + + assert workflow_service.synced_hashes == [] + + +def test_refused_overwrite_leaves_the_app_and_cached_dependencies_alone( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: + app = _app(mode=AppMode.WORKFLOW) + service, workflow_service, _ = _guarded_overwrite(monkeypatch, unbound_session, draft=_workflow_draft(app)) + cached: list[str] = [] + monkeypatch.setattr("services.app_dsl_service.redis_client.setex", lambda key, _expiry, _value: cached.append(key)) + dependency = PluginDependency( + type=PluginDependencyType.Package, + value=PluginDependency.Package(plugin_unique_identifier="langgenius/openai:1.0.0@abc"), + ) + + with pytest.raises(DraftChangedError): + service._create_or_update_app( + app=app, + data={"app": {"mode": AppMode.WORKFLOW.value, "name": "After"}, "workflow": {"graph": {}}}, + account=_account(), + dependencies=[dependency], + draft_hash="exported", + ) + + assert cached == [] + assert app.name == "Existing app" + assert workflow_service.synced_hashes == [] + + +def _refuse_changed_draft(forwarded: list[str | None]) -> Callable[..., App]: + def create_or_update_app(*, draft_hash: str | None = None, **_: object) -> App: + forwarded.append(draft_hash) + raise DraftChangedError() + + return create_or_update_app + + +def test_import_app_reraises_a_changed_draft_and_keeps_the_hash_for_the_confirm( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: + pending_imports: dict[str, str] = {} + monkeypatch.setattr( + "services.app_dsl_service.redis_client.setex", + lambda key, _expiry, value: pending_imports.__setitem__(key, value), + ) + service = AppDslService(session=unbound_session) + forwarded: list[str | None] = [] + monkeypatch.setattr(service, "_create_or_update_app", _refuse_changed_draft(forwarded)) + + with pytest.raises(DraftChangedError): + service.import_app( + account=_account(), + import_mode="yaml-content", + yaml_content=yaml.safe_dump( + {"version": CURRENT_APP_DSL_VERSION, "kind": "app", "app": {"name": "Imported", "mode": "chat"}} + ), + draft_hash="exported", + ) + assert forwarded == ["exported"] + + pending = service.import_app( + account=_account(), + import_mode="yaml-content", + yaml_content="version: 99.0.0\nkind: app\napp: {name: Test, mode: workflow}\n", + draft_hash="exported", + ) + assert pending.status == ImportStatus.PENDING + stored = PendingData.model_validate_json(pending_imports[f"app_import_info:{pending.id}"]) + assert stored.draft_hash == "exported" + + +def test_confirm_import_checks_the_pending_draft_hash( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: + pending = PendingData( + tenant_id="tenant-1", + account_id="account-1", + import_mode="yaml-content", + yaml_content=yaml.safe_dump( + {"version": CURRENT_APP_DSL_VERSION, "kind": "app", "app": {"name": "Imported", "mode": "chat"}} + ), + draft_hash="exported", + ) + monkeypatch.setattr("services.app_dsl_service.redis_client.get", lambda _key: pending.model_dump_json()) + deleted: list[str] = [] + monkeypatch.setattr("services.app_dsl_service.redis_client.delete", deleted.append) + service = AppDslService(unbound_session) + forwarded: list[str | None] = [] + monkeypatch.setattr(service, "_create_or_update_app", _refuse_changed_draft(forwarded)) + + with pytest.raises(DraftChangedError): + service.confirm_import(import_id="pending-import", account=_account()) + + assert forwarded == ["exported"] + assert deleted == [] + + +def test_pending_data_without_a_draft_hash_still_reads() -> None: + older = PendingData.model_validate_json(_PENDING_DATA_JSON).model_dump_json(exclude={"draft_hash"}) + assert PendingData.model_validate_json(older).draft_hash is None + + @pytest.mark.parametrize("confirmed", [False, True]) @pytest.mark.parametrize("overwrite", [False, True]) def test_import_creation_marker_tracks_persisted_app( diff --git a/api/tests/unit_tests/services/test_model_provider_service.py b/api/tests/unit_tests/services/test_model_provider_service.py index 6659d5ac7d0..64146e1db67 100644 --- a/api/tests/unit_tests/services/test_model_provider_service.py +++ b/api/tests/unit_tests/services/test_model_provider_service.py @@ -7,7 +7,8 @@ from sqlalchemy import event from sqlalchemy.engine import Engine from sqlalchemy.orm import Session -from core.entities.model_entities import ModelStatus +from core.entities.model_entities import ModelStatus, ModelWithProviderEntity +from core.entities.provider_configuration import ProviderConfigurations from core.entities.provider_entities import CredentialConfiguration from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginModelProviderBinding @@ -535,6 +536,20 @@ class TestModelProviderServiceConfiguration: assert "ORDER BY provider_credentials.created_at DESC, provider_credentials.id DESC" in statements[1] assert "provider_model_credentials" in statements[2] + def test_list_models_should_pass_filters_through(self, monkeypatch: pytest.MonkeyPatch) -> None: + service, manager = _create_service_with_mocked_manager() + filters: list[dict[str, object]] = [] + + def get_models(_self: ProviderConfigurations, **kwargs: object) -> list[ModelWithProviderEntity]: + filters.append(kwargs) + return [] + + monkeypatch.setattr(ProviderConfigurations, "get_models", get_models) + manager.get_configurations.return_value = ProviderConfigurations(tenant_id="tenant-1") + + assert service.list_models("tenant-1", provider=None, model_type=ModelType.LLM) == [] + assert filters == [{"provider": None, "model_type": ModelType.LLM}] + def test_get_models_by_provider_should_wrap_model_entities_with_tenant_context(self) -> None: service, manager = _create_service_with_mocked_manager() @@ -604,7 +619,7 @@ class TestModelProviderServiceDelegation: }, "create_provider_credential", ({"token": "abc"}, "A"), - None, + "new-credential-id", ), ( "update_provider_credential", @@ -663,6 +678,8 @@ class TestModelProviderServiceDelegation: provider_method.assert_called_once_with(provider_call_kwargs) if method_name == "get_provider_credential": assert result == {"token": "abc"} + if method_name == "create_provider_credential": + assert result == "new-credential-id" @pytest.mark.parametrize( ("method_name", "method_kwargs", "provider_method_name", "expected_kwargs", "provider_return"), @@ -710,7 +727,7 @@ class TestModelProviderServiceDelegation: "credentials": {"api_key": "x"}, "credential_name": "cred-a", }, - None, + "new-credential-id", ), ( "update_model_credential", @@ -807,6 +824,8 @@ class TestModelProviderServiceDelegation: getattr(provider_configuration, provider_method_name).assert_called_once_with(**expected_kwargs) if method_name == "get_model_credential": assert result == {"api_key": "x"} + if method_name == "create_model_credential": + assert result == "new-credential-id" @pytest.mark.parametrize( ("method_name", "method_kwargs", "provider_method_name", "expected_kwargs"), diff --git a/api/tests/unit_tests/services/test_oauth_device_adapters.py b/api/tests/unit_tests/services/test_oauth_device_adapters.py index df9981e1b4c..e503bf18612 100644 --- a/api/tests/unit_tests/services/test_oauth_device_adapters.py +++ b/api/tests/unit_tests/services/test_oauth_device_adapters.py @@ -172,18 +172,7 @@ def test_approval_nonce_release_only_removes_matching_reservation(config_overrid assert gateway.reserve_approval_nonce("nonce-1", "reservation-2") is True -@pytest.mark.parametrize( - ("configured", "expected"), - [(None, 14), ("invalid", 14), ("0", 1), ("500", 365), ("30", 30)], -) -def test_token_ttl_policy_owns_environment_parsing( - monkeypatch: pytest.MonkeyPatch, - configured: str | None, - expected: int, -) -> None: - if configured is None: - monkeypatch.delenv("OAUTH_TTL_DAYS", raising=False) - else: - monkeypatch.setenv("OAUTH_TTL_DAYS", configured) +def test_token_ttl_policy_reads_the_configured_days(config_overrides: Callable[..., None]) -> None: + config_overrides(OAUTH_TTL_DAYS=30) - assert EnvironmentOAuthDeviceTokenTTLPolicy().ttl_days("workspace-1") == expected + assert EnvironmentOAuthDeviceTokenTTLPolicy().ttl_days("workspace-1") == 30 diff --git a/api/tests/unit_tests/services/test_webapp_access_adapters.py b/api/tests/unit_tests/services/test_webapp_access_adapters.py index 03c473d631e..00e04913044 100644 --- a/api/tests/unit_tests/services/test_webapp_access_adapters.py +++ b/api/tests/unit_tests/services/test_webapp_access_adapters.py @@ -27,6 +27,10 @@ class EnterpriseWebAppAuthStub: raise self.access_mode_error return WebAppSettings(accessMode=self.access_mode) + def update_app_access_mode(self, app_id: str, access_mode: str) -> None: + del app_id + self.access_mode = access_mode + def is_user_allowed_to_access_webapp(self, user_id: str, app_id: str) -> bool: self.permission_calls.append((user_id, app_id)) if self.permission_error is not None: diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 3b15a5f671d..df4161daf04 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -62,6 +62,7 @@ from services.errors.workflow_service import DraftWorkflowDeletionError, Workflo from services.workflow_ref_service import WorkflowRef from services.workflow_service import ( WorkflowService, + _merge_environment_variable_patch, _rebuild_file_for_user_inputs_in_start_node, _rebuild_single_file, _setup_variable_pool, @@ -1531,6 +1532,39 @@ class TestWorkflowService: assert [workflow.id for workflow in workflows] == ["workflow-draft", "workflow-3"] + def test_get_all_published_workflow_excludes_draft_when_requested( + self, workflow_service: WorkflowService, sqlite_session: Session + ): + """`include_draft=False` filters the draft out at query level so callers that + only understand published versions (e.g. the openapi version-list op, where the + draft breaks restore and ordering) get an exact page. + """ + app = TestWorkflowAssociatedDataFactory.create_app(workflow_id="workflow-3") + app_created_at = datetime(2026, 1, 1) + + sqlite_session.add( + TestWorkflowAssociatedDataFactory.create_workflow( + workflow_id="workflow-draft", + version=Workflow.VERSION_DRAFT, + created_at=app_created_at, + ) + ) + sqlite_session.add( + TestWorkflowAssociatedDataFactory.create_workflow( + workflow_id="workflow-3", + version="2026-02-01 00:00:00", + created_at=app_created_at + timedelta(days=1), + ) + ) + sqlite_session.commit() + + workflows, has_more = workflow_service.get_all_published_workflow( + session=sqlite_session, app_model=app, page=1, limit=10, user_id=None, include_draft=False + ) + + assert [workflow.id for workflow in workflows] == ["workflow-3"] + assert has_more is False + def test_get_all_published_workflow_has_more(self, workflow_service: WorkflowService, sqlite_session: Session): """ Test get_all_published_workflow indicates has_more when results exceed limit. @@ -2538,28 +2572,25 @@ class TestWorkflowServiceCredentialValidation: ) -> None: """Missing provider or model in node_data should be a no-op.""" # Arrange - workflow = self._make_workflow([]) node_data: dict[str, Any] = {} # no model key # Act + Assert (no error expected) - service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=sqlite_session) + service._validate_load_balancing_credentials("tenant-1", node_data, "node-1", session=sqlite_session) def test_validate_load_balancing_credentials_should_skip_when_lb_not_enabled( self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange - workflow = self._make_workflow([]) node_data = {"model": {"provider": "openai", "name": "gpt-4"}} # Act + Assert (no error expected) with patch.object(service, "_is_load_balancing_enabled", return_value=False): - service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=sqlite_session) + service._validate_load_balancing_credentials("tenant-1", node_data, "node-1", session=sqlite_session) def test_validate_load_balancing_credentials_should_raise_when_compliance_fails( self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange - workflow = self._make_workflow([]) node_data = {"model": {"provider": "openai", "name": "gpt-4"}} lb_configs = [{"credential_id": "cred-lb-1"}] @@ -2573,7 +2604,7 @@ class TestWorkflowServiceCredentialValidation: ), ): with pytest.raises(ValueError, match="Invalid load balancing credentials"): - service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=sqlite_session) + service._validate_load_balancing_credentials("tenant-1", node_data, "node-1", session=sqlite_session) # =========================================================================== @@ -3641,3 +3672,18 @@ class TestWorkflowServiceFreeNodeExecution: assert node.title == "Human Input" assert node.node_data is node_data assert node.variable_pool is variable_pool + + +def _env(variable_id: str, name: str) -> StringVariable: + return StringVariable(id=variable_id, name=name, value="v", selector=["env", name]) + + +@pytest.mark.parametrize( + "upsert", + [_env("new-id", "TAKEN"), _env("other-id", "TAKEN")], + ids=["new id reuses a name", "rename onto another variable's name"], +) +def test_environment_patch_rejects_a_name_another_id_uses(upsert: StringVariable) -> None: + current = [_env("taken-id", "TAKEN"), _env("other-id", "OTHER")] + with pytest.raises(ValueError, match="Environment variable names must be unique"): + _merge_environment_variable_patch(current, [upsert], []) diff --git a/api/tests/unit_tests/services/test_workflow_variable_reference_validator.py b/api/tests/unit_tests/services/test_workflow_variable_reference_validator.py index 94f85c8b97b..54934a924d6 100644 --- a/api/tests/unit_tests/services/test_workflow_variable_reference_validator.py +++ b/api/tests/unit_tests/services/test_workflow_variable_reference_validator.py @@ -5,7 +5,10 @@ is flagged. A producer that always runs, a parallel branch, and an aggregator are not. """ +import pytest + from services.workflow_variable_reference_validator import ( + branch_handles, format_variable_reference_errors, validate_variable_references, ) @@ -437,6 +440,154 @@ class TestValidateVariableReferences: assert validate_variable_references(graph) == [] +def _routed_agent(*route_ids: str, error_strategy: str | None = None, enabled: object = True) -> dict[str, object]: + data: dict[str, object] = { + "type": "agent", + "agent_output_routes": {"enabled": enabled, "routes": [{"id": route_id} for route_id in route_ids]}, + } + if error_strategy is not None: + data["error_strategy"] = error_strategy + return data + + +class TestBranchHandles: + @pytest.mark.parametrize( + ("data", "expected"), + [ + ({"type": "if-else", "cases": [{"case_id": "a"}, {"case_id": "b"}]}, {"a", "b", "false"}), + ({"type": "if-else"}, {"true", "false"}), + ({"type": "question-classifier", "classes": [{"id": "c1"}, {"id": "c2"}]}, {"c1", "c2"}), + ({"type": "human-input", "user_actions": [{"id": "ok"}]}, {"ok", "__timeout"}), + ({"type": "tool", "error_strategy": "fail-branch"}, {"source", "fail-branch"}), + ( + {"type": "if-else", "cases": [{"case_id": "a"}], "error_strategy": "fail-branch"}, + {"a", "false", "fail-branch"}, + ), + ( + {"type": "question-classifier", "classes": [{"id": "c1"}], "error_strategy": "fail-branch"}, + {"c1", "fail-branch"}, + ), + ( + {"type": "human-input", "user_actions": [{"id": "ok"}], "error_strategy": "fail-branch"}, + {"ok", "__timeout", "fail-branch"}, + ), + (_routed_agent("yes", "no"), {"yes", "no"}), + (_routed_agent("yes", "no", error_strategy="fail-branch"), {"yes", "no", "fail-branch"}), + (_routed_agent("yes", "no", enabled=False, error_strategy="fail-branch"), {"source", "fail-branch"}), + ], + ) + def test_branch_nodes_list_their_handles(self, data: dict[str, object], expected: set[str]) -> None: + assert branch_handles(data) == expected + + @pytest.mark.parametrize( + "data", + [ + {"type": "tool"}, + {"type": "llm", "error_strategy": "default-value"}, + _routed_agent("yes", "no", enabled=False), + _routed_agent("yes", "no", enabled="true"), + {"type": "agent", "agent_output_routes": None}, + {"type": "agent"}, + {"type": "future-node-type"}, + {}, + ], + ) + def test_nodes_that_follow_every_edge_have_none(self, data: dict[str, object]) -> None: + assert branch_handles(data) is None + + @pytest.mark.parametrize( + ("data", "expected"), + [ + ({"type": "question-classifier", "classes": [{"name": "no id"}, "c", None, {"id": "c1"}]}, {"c1"}), + ({"type": "question-classifier", "classes": None}, set()), + ({"type": "human-input", "user_actions": 5}, {"__timeout"}), + ({"type": "if-else", "cases": [{"id": "x"}, {"case_id": "a"}]}, {"a", "false"}), + ({"type": "agent", "agent_output_routes": {"enabled": True, "routes": [{"name": "x"}, 3]}}, set()), + ({"type": "agent", "agent_output_routes": {"enabled": True, "routes": "yes"}}, set()), + ], + ) + def test_malformed_items_are_skipped(self, data: dict[str, object], expected: set[str]) -> None: + assert branch_handles(data) == expected + + +class TestAgentOutputRoutes: + def _graph(self, agent_data: dict[str, object], *edges: dict[str, str]) -> dict[str, object]: + return { + "nodes": [ + _node("start", "start"), + {"id": "agent", "data": {**agent_data, "title": "Agent"}}, + _node("prod", "tool"), + _node("cons", "llm", selector=["prod", "text"]), + ], + "edges": [_edge("start", "agent"), *edges], + } + + def test_a_route_that_skips_the_producer_is_flagged(self) -> None: + graph = self._graph(_routed_agent("yes", "no"), _edge("agent", "prod", "yes"), _edge("agent", "cons", "no")) + assert [(i.node_id, i.referenced_node_id) for i in validate_variable_references(graph)] == [("cons", "prod")] + + def test_an_unwired_route_can_skip_the_producer(self) -> None: + graph = self._graph(_routed_agent("yes", "no"), _edge("agent", "prod", "yes"), _edge("start", "cons")) + assert [(i.node_id, i.referenced_node_id) for i in validate_variable_references(graph)] == [("cons", "prod")] + + def test_disabled_routes_follow_every_edge(self) -> None: + graph = self._graph(_routed_agent("yes", "no", enabled=False), _edge("agent", "prod"), _edge("prod", "cons")) + assert validate_variable_references(graph) == [] + + +class TestFailBranchOnBranchNodes: + def test_classifier_whose_every_exit_runs_the_producer_is_not_flagged(self) -> None: + router = _node("router", "question-classifier") + _node_data(router)["classes"] = [{"id": "c1"}, {"id": "c2"}] + _node_data(router)["error_strategy"] = "fail-branch" + graph = { + "nodes": [ + _node("start", "start"), + router, + _node("prod", "tool"), + _node("cons", "llm", selector=["prod", "x"]), + ], + "edges": [ + _edge("start", "router"), + _edge("start", "cons"), + _edge("router", "prod", "c1"), + _edge("router", "prod", "c2"), + _edge("router", "prod", "fail-branch"), + ], + } + assert validate_variable_references(graph) == [] + + def test_ordinary_node_with_fail_branch_still_leaves_by_source(self) -> None: + tool = _node("tool", "tool") + _node_data(tool)["error_strategy"] = "fail-branch" + graph = { + "nodes": [ + _node("start", "start"), + tool, + _node("prod", "code"), + _node("cons", "llm", selector=["prod", "x"]), + ], + "edges": [_edge("start", "tool"), _edge("start", "cons"), _edge("tool", "prod", "fail-branch")], + } + assert [(i.node_id, i.referenced_node_id) for i in validate_variable_references(graph)] == [("cons", "prod")] + + +class TestMalformedBranchData: + def test_branch_items_without_ids_do_not_raise(self) -> None: + router = _node("router", "question-classifier") + _node_data(router)["classes"] = [{"name": "no id"}, {"id": "c1"}] + graph = { + "nodes": [ + _node("start", "start"), + router, + _node("prod", "tool"), + _node("cons", "llm", selector=["prod", "x"]), + ], + "edges": [_edge("start", "router"), _edge("router", "prod", "c1"), _edge("prod", "cons")], + } + assert validate_variable_references(graph) == [] + + class TestFormatVariableReferenceErrors: def test_message_includes_titles_and_count(self) -> None: """The rendered message names both nodes and reports the issue count.""" diff --git a/api/tests/unit_tests/services/workflow/test_dsl_import.py b/api/tests/unit_tests/services/workflow/test_dsl_import.py new file mode 100644 index 00000000000..40bc52c4d9e --- /dev/null +++ b/api/tests/unit_tests/services/workflow/test_dsl_import.py @@ -0,0 +1,174 @@ +from collections.abc import Generator, Mapping +from contextlib import contextmanager + +import pytest +import yaml + +from constants import HIDDEN_VALUE +from graphon.variables import VariableBase +from machinery.context import RequestContext +from services.app.console_service import ConsoleAppNotFoundError +from services.dsl_content import DSL_MAX_SIZE +from services.entities.dsl_entities import ImportMode +from services.errors.app import DraftChangedError +from services.workflow import dsl_import +from services.workflow.dsl_import import ( + DslImportService, + DslImportStore, + DslImportTarget, + DslNotCheckableError, + DslRefusedError, +) +from services.workflow.graph_check import IssueCode, IssueSeverity, ResourceCheck + +_CONTEXT = RequestContext("request-1", None, "account-1", "workspace-1") +_START: dict[str, object] = {"id": "start", "data": {"type": "start", "title": "Start", "variables": []}} +_END: dict[str, object] = {"id": "end", "data": {"type": "end", "title": "End", "outputs": []}} +_ANSWER: dict[str, object] = {"id": "a", "data": {"type": "answer", "title": "A", "answer": "hi"}} +_TARGET = DslImportTarget(mode="workflow", draft_token="token-1", stored_secret_ids=frozenset({"s-1"})) + + +class _Store(DslImportStore): + """Answers with fixed data and records what the service asked for.""" + + def __init__(self, target: DslImportTarget | None = None, *, problem: str | None = None) -> None: + self._target = target + self._problem = problem + self.events: list[str] = [] + + def load_target(self, context: RequestContext, app_id: str) -> DslImportTarget: + self.events.append(f"load:{context.active_workspace_id}/{app_id}") + if self._target is None: + raise ConsoleAppNotFoundError("App not found") + return self._target + + @contextmanager + def credential_check(self, workspace_id: str, environment: Mapping[str, VariableBase]) -> Generator[ResourceCheck]: + self.events.append(f"open:{workspace_id}:{sorted(environment)}") + yield lambda _node: self._problem + self.events.append("close") + + +def _dsl(mode: str, *nodes: Mapping[str, object], environment: list[dict[str, object]] | None = None) -> str: + workflow: dict[str, object] = {"graph": {"nodes": list(nodes), "edges": []}} + if environment is not None: + workflow["environment_variables"] = environment + return yaml.safe_dump({"app": {"mode": mode, "name": "x"}, "workflow": workflow}) + + +def _secret(variable_id: str) -> dict[str, object]: + return {"id": variable_id, "name": variable_id.upper(), "value_type": "secret", "value": ""} + + +@pytest.fixture +def fetched(monkeypatch: pytest.MonkeyPatch) -> list[str]: + urls: list[str] = [] + + def fetch(url: str) -> str: + urls.append(url) + return _dsl("workflow", _START, _END) + + monkeypatch.setattr(dsl_import, "fetch_yaml_url", fetch) + return urls + + +def _prepare(store: _Store, *, source: str, app_id: str | None = "app-1", draft_hash: str | None = None) -> str: + mode = ImportMode.YAML_URL if source.startswith("https://") else ImportMode.YAML_CONTENT + return DslImportService(store=store).prepare( + _CONTEXT, mode=mode, source=source, app_id=app_id, draft_hash=draft_hash + ) + + +@pytest.mark.parametrize( + ("target", "app_id"), + [ + pytest.param(_TARGET, "app-1", id="hash-mismatch"), + pytest.param(DslImportTarget("workflow", None, frozenset()), "app-1", id="no-draft"), + pytest.param(_TARGET, None, id="no-app"), + ], +) +def test_prepare_refuses_a_stale_draft_hash_before_fetching( + fetched: list[str], target: DslImportTarget, app_id: str | None +) -> None: + with pytest.raises(DraftChangedError): + _prepare(_Store(target), source="https://example.com/app.yml", app_id=app_id, draft_hash="old") + + assert fetched == [] + + +def test_prepare_fetches_the_url_once_the_draft_hash_matches(fetched: list[str]) -> None: + _prepare(_Store(_TARGET), source="https://example.com/app.yml", draft_hash="token-1") + + assert fetched == ["https://example.com/app.yml"] + + +def test_prepare_keeps_only_the_secrets_the_draft_stores() -> None: + source = _dsl("workflow", _START, _END, environment=[_secret("s-1"), _secret("s-2")]) + + prepared = yaml.safe_load(_prepare(_Store(_TARGET), source=source)) + + values = {v["id"]: v["value"] for v in prepared["workflow"]["environment_variables"]} + assert values == {"s-1": HIDDEN_VALUE, "s-2": ""} + + +def test_prepare_refuses_a_graph_that_does_not_fit_the_target_mode() -> None: + target = DslImportTarget("advanced-chat", None, frozenset()) + + with pytest.raises(DslRefusedError) as exc_info: + _prepare(_Store(target), source=_dsl("workflow", _START, _END)) + + assert [(i.code, i.node_id) for i in exc_info.value.issues] == [(IssueCode.MODE_INCOMPATIBLE, "end")] + + +@pytest.mark.parametrize("source", [_dsl("chat"), "app: [", _dsl("future-mode", _ANSWER)]) +def test_prepare_passes_through_a_dsl_the_check_cannot_read(source: str) -> None: + assert _prepare(_Store(), source=source, app_id=None) == source + + +def test_prepare_answers_a_missing_app() -> None: + with pytest.raises(ConsoleAppNotFoundError): + _prepare(_Store(), source=_dsl("workflow", _START, _END)) + + +def test_check_reports_credential_problems_inside_the_store_context() -> None: + store = _Store(_TARGET, problem="no key") + + issues = DslImportService(store=store).check(_CONTEXT, _dsl("workflow", _START, _END), "app-1") + + assert {(i.code, i.code.severity, i.message) for i in issues} == { + (IssueCode.RESOURCE_UNAVAILABLE, IssueSeverity.WARNING, "no key") + } + assert store.events == ["load:workspace-1/app-1", "open:workspace-1:[]", "close"] + + +def test_check_uses_the_target_apps_mode() -> None: + store = _Store(DslImportTarget("advanced-chat", None, frozenset())) + + issues = DslImportService(store=store).check(_CONTEXT, _dsl("workflow", _START, _END), "app-1") + + assert [(i.code, i.node_id) for i in issues] == [(IssueCode.MODE_INCOMPATIBLE, "end")] + + +def test_check_without_an_app_reads_no_target() -> None: + store = _Store() + + DslImportService(store=store).check(_CONTEXT, _dsl("workflow", _START, _END), None) + + assert store.events == ["open:workspace-1:[]", "close"] + + +def _padded(yaml_content: str) -> str: + """A valid DSL over the size limit only because of a trailing comment.""" + return yaml_content + "#" + "x" * DSL_MAX_SIZE + "\n" + + +def test_check_refuses_a_dsl_over_the_size_limit() -> None: + with pytest.raises(DslNotCheckableError, match="10MB"): + DslImportService(store=_Store()).check(_CONTEXT, _padded(_dsl("workflow", _START, _END)), None) + + +def test_prepare_hands_an_oversized_dsl_to_the_import_unchanged() -> None: + """Re-dumping drops comments, so a padded DSL must reach the import's own size check as sent.""" + source = _padded(_dsl("workflow", _START, _END)) + + assert _prepare(_Store(), source=source, app_id=None) == source diff --git a/api/tests/unit_tests/services/workflow/test_graph_check.py b/api/tests/unit_tests/services/workflow/test_graph_check.py new file mode 100644 index 00000000000..b61d63d4761 --- /dev/null +++ b/api/tests/unit_tests/services/workflow/test_graph_check.py @@ -0,0 +1,273 @@ +from collections.abc import Mapping + +import pytest +from sqlalchemy.orm import Session + +from models import AppMode +from services.workflow.graph_check import IssueCode, IssueSeverity, check_graph, credential_check +from services.workflow_service import WorkflowService + + +def _node(node_id: str, data: dict[str, object]) -> dict[str, object]: + return {"id": node_id, "data": data} + + +START = _node("start", {"type": "start", "title": "Start", "variables": []}) +END = _node("end", {"type": "end", "title": "End", "outputs": []}) + + +def _template(selector: list[str]) -> dict[str, object]: + return _node( + "t", + { + "type": "template-transform", + "title": "T", + "template": "{{ x }}", + "variables": [{"variable": "x", "value_selector": selector}], + }, + ) + + +def _codes(graph: Mapping[str, object], mode: AppMode = AppMode.WORKFLOW) -> list[IssueCode]: + return [issue.code for issue in check_graph(graph, mode=mode)] + + +def test_clean_graph_has_no_issues() -> None: + graph = { + "nodes": [START, END], + "edges": [{"id": "e", "source": "start", "target": "end", "sourceHandle": "source"}], + } + assert _codes(graph) == [] + + +def test_graph_without_nodes() -> None: + assert _codes({"edges": []}) == [IssueCode.GRAPH_INVALID] + assert _codes({"nodes": "x"}) == [IssueCode.GRAPH_INVALID] + + +@pytest.mark.parametrize( + ("graph", "loc"), + [ + ({"nodes": [{"id": "a", "data": None}], "edges": []}, ("nodes", "a", "data")), + ({"nodes": [{"id": "a", "data": [1]}], "edges": []}, ("nodes", "a", "data")), + ({"nodes": [{"id": "a"}], "edges": []}, ("nodes", "a", "data")), + ({"nodes": [5], "edges": []}, ("nodes", 0)), + ({"nodes": [START, None], "edges": []}, ("nodes", 1)), + ({"nodes": [{"data": {"type": "start"}}], "edges": []}, ("nodes", 0, "id")), + ({"nodes": [{"id": {"x": 1}, "data": {"type": "start"}}], "edges": []}, ("nodes", 0, "id")), + ({"nodes": [{"id": "", "data": {"type": "start"}}], "edges": []}, ("nodes", 0, "id")), + ({"nodes": [START, START], "edges": []}, ("nodes", 1, "id")), + ({"nodes": [START, {"id": "start", "data": None}], "edges": []}, ("nodes", 1, "id")), + ({"nodes": [START, {"id": "start", "type": "custom-note", "data": {}}], "edges": []}, ("nodes", 1, "id")), + ({"nodes": [START], "edges": 5}, ("edges",)), + ({"nodes": [START], "edges": "start->end"}, ("edges",)), + ({"nodes": [START], "edges": [{"source": "start", "target": "end"}, 7]}, ("edges", 1)), + ], +) +def test_malformed_shapes_are_graph_issues_not_errors( + sqlite_session: Session, graph: Mapping[str, object], loc: tuple[object, ...] +) -> None: + issues = check_graph(graph, mode=AppMode.WORKFLOW, resources=credential_check("ws-1", {}, sqlite_session)) + assert [(i.code, i.loc) for i in issues] == [(IssueCode.GRAPH_INVALID, loc)] + + +def test_every_repeat_of_a_node_id_is_reported() -> None: + issues = check_graph({"nodes": [START, END, START, START], "edges": []}, mode=AppMode.WORKFLOW) + assert [(i.code, i.node_id, i.loc) for i in issues] == [ + (IssueCode.GRAPH_INVALID, "start", ("nodes", 2, "id")), + (IssueCode.GRAPH_INVALID, "start", ("nodes", 3, "id")), + ] + + +def test_missing_edges_are_no_edges() -> None: + assert _codes({"nodes": [START]}) == [] + + +def test_mode_incompatible_nodes() -> None: + answer = _node("a", {"type": "answer", "title": "A", "answer": "hi"}) + issues = check_graph({"nodes": [START, answer], "edges": []}, mode=AppMode.WORKFLOW) + assert [(i.code, i.node_id) for i in issues] == [(IssueCode.MODE_INCOMPATIBLE, "a")] + + +def test_unknown_type_and_invalid_data_are_located() -> None: + weird = _node("w", {"type": "future-node", "title": "W"}) + llm = _node("l", {"type": "llm", "title": "L"}) + issues = {i.node_id: i for i in check_graph({"nodes": [START, weird, llm], "edges": []}, mode=AppMode.WORKFLOW)} + assert issues["w"].code is IssueCode.UNKNOWN_NODE_TYPE + assert issues["l"].code is IssueCode.NODE_DATA_INVALID + assert issues["l"].loc[:3] == ("nodes", "l", "data") + + +def test_edges_must_point_at_nodes() -> None: + graph = {"nodes": [START], "edges": [{"id": "e", "source": "start", "target": "ghost", "sourceHandle": "source"}]} + assert _codes(graph) == [IssueCode.EDGE_ENDPOINT_MISSING] + + +def test_if_else_handles() -> None: + branch = _node( + "if", + {"type": "if-else", "title": "If", "cases": [{"case_id": "c1", "logical_operator": "and", "conditions": []}]}, + ) + ok = {"id": "e1", "source": "if", "target": "end", "sourceHandle": "c1"} + ok_false = {"id": "e2", "source": "if", "target": "end", "sourceHandle": "false"} + bad = {"id": "e3", "source": "if", "target": "end", "sourceHandle": "true"} + graph = {"nodes": [START, branch, END], "edges": [ok, ok_false, bad]} + assert [(i.code, i.loc) for i in check_graph(graph, mode=AppMode.WORKFLOW)] == [ + (IssueCode.BRANCH_HANDLE_INVALID, ("edges", "e3", "sourceHandle")) + ] + + +def test_references_to_missing_nodes() -> None: + assert _codes({"nodes": [START, _template(["ghost", "text"])], "edges": []}) == [IssueCode.REFERENCE_MISSING] + + +def test_system_selectors_are_not_missing() -> None: + assert _codes({"nodes": [START, _template(["sys", "user_id"])], "edges": []}) == [] + + +def test_resource_problems_are_warnings() -> None: + issues = check_graph({"nodes": [START, END], "edges": []}, mode=AppMode.WORKFLOW, resources=lambda _node: "no key") + assert {i.code for i in issues} == {IssueCode.RESOURCE_UNAVAILABLE} + assert all(i.code.severity is IssueSeverity.WARNING for i in issues) + + +def test_note_widgets_are_not_nodes() -> None: + note = {"id": "n", "type": "custom-note", "data": {"type": "", "title": "", "text": "a note"}} + assert _codes({"nodes": [START, note], "edges": []}) == [] + + +def test_legacy_if_else_branches_on_true() -> None: + branch = _node("if", {"type": "if-else", "title": "If", "logical_operator": "and", "conditions": []}) + edges = [ + {"id": "e1", "source": "if", "target": "end", "sourceHandle": "true"}, + {"id": "e2", "source": "if", "target": "end", "sourceHandle": "false"}, + ] + assert _codes({"nodes": [START, branch, END], "edges": edges}) == [] + + +def _unconnected(graph: Mapping[str, object]) -> list[str]: + issues = check_graph(graph, mode=AppMode.WORKFLOW) + return [i.message for i in issues if i.code is IssueCode.BRANCH_UNCONNECTED] + + +def test_fail_branch_classifier_is_not_asked_for_a_source_edge() -> None: + classifier = _node( + "qc", + { + "type": "question-classifier", + "title": "Q", + "classes": [{"id": "c1", "name": "a"}], + "error_strategy": "fail-branch", + }, + ) + edges = [ + {"id": "e0", "source": "start", "target": "qc", "sourceHandle": "source"}, + {"id": "e1", "source": "qc", "target": "end", "sourceHandle": "c1"}, + {"id": "e2", "source": "qc", "target": "end", "sourceHandle": "fail-branch"}, + ] + assert _unconnected({"nodes": [START, classifier, END], "edges": edges}) == [] + + +def test_fail_branch_ordinary_node_still_needs_its_source_edge() -> None: + code = _node("c", {"type": "code", "title": "C", "error_strategy": "fail-branch"}) + edges = [ + {"id": "e0", "source": "start", "target": "c", "sourceHandle": "source"}, + {"id": "e1", "source": "c", "target": "end", "sourceHandle": "fail-branch"}, + ] + [message] = _unconnected({"nodes": [START, code, END], "edges": edges}) + assert "'source'" in message + + +def test_scalar_branch_lists_are_reported_not_raised() -> None: + if_else = _node("if", {"type": "if-else", "title": "If", "cases": 5}) + classifier = _node("qc", {"type": "question-classifier", "title": "Q", "classes": 7}) + edges = [ + {"id": "e1", "source": "if", "target": "start", "sourceHandle": "true"}, + {"id": "e2", "source": "qc", "target": "start", "sourceHandle": "c1"}, + ] + issues = check_graph({"nodes": [START, if_else, classifier], "edges": edges}, mode=AppMode.WORKFLOW) + assert {i.node_id for i in issues if i.code is IssueCode.NODE_DATA_INVALID} == {"if", "qc"} + + +def test_unexpected_node_validation_error_is_located_and_later_nodes_are_checked() -> None: + http = _node( + "h", + { + "type": "http-request", + "title": "H", + "method": "get", + "url": "https://x", + "authorization": {"type": "oauth2", "config": {"type": "basic", "api_key": "k"}}, + }, + ) + llm = _node("l", {"type": "llm", "title": "L"}) + issues = check_graph({"nodes": [START, http, llm], "edges": []}, mode=AppMode.WORKFLOW) + assert IssueCode.GRAPH_INVALID not in [i.code for i in issues] + by_node = {i.node_id: i for i in issues} + assert (by_node["h"].code, by_node["h"].loc) == (IssueCode.NODE_DATA_INVALID, ("nodes", "h", "data")) + assert by_node["l"].code is IssueCode.NODE_DATA_INVALID + + +@pytest.fixture(autouse=True) +def service_without_storage(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(WorkflowService, "__init__", lambda _self: None) + monkeypatch.setattr(WorkflowService, "_is_load_balancing_enabled", lambda *_args: False) + + +def _validate_llm_raising(monkeypatch: pytest.MonkeyPatch, error: Exception) -> list[tuple[str, str, str]]: + calls: list[tuple[str, str, str]] = [] + + def validate(_self: WorkflowService, tenant_id: str, provider: str, model_name: str) -> None: + calls.append((tenant_id, provider, model_name)) + raise error + + monkeypatch.setattr(WorkflowService, "_validate_llm_model_config", validate) + return calls + + +def test_credential_check_reports_what_publish_refuses_for_a_node( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + llm = _node("l", {"type": "llm", "model": {"provider": "openai", "name": "gpt-4o"}}) + check = credential_check("ws-1", {}, sqlite_session) + calls = _validate_llm_raising(monkeypatch, ValueError("no credential")) + + assert check(llm) == "no credential" + assert calls == [("ws-1", "openai", "gpt-4o")] + + +def test_credential_check_wraps_unexpected_errors_with_the_node( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + llm = _node("l", {"type": "llm", "model": {"provider": "openai", "name": "gpt-4o"}}) + check = credential_check("ws-1", {}, sqlite_session) + _validate_llm_raising(monkeypatch, RuntimeError("boom")) + + assert check(llm) == "Node l (llm): boom" + + +@pytest.mark.parametrize("node_type", ["knowledge-retrieval", "parameter-extractor", "question-classifier"]) +def test_credential_check_matches_publish_for_model_nodes_publish_skips( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, node_type: str +) -> None: + """Publish lists these types with underscores, so it never checks them; the release check must agree.""" + node = _node("n", {"type": node_type, "model": {"provider": "openai", "name": "gpt-4o"}}) + check = credential_check("ws-1", {}, sqlite_session) + calls = _validate_llm_raising(monkeypatch, ValueError("no credential")) + + assert check(node) is None + assert calls == [] + + +def test_chatflow_starter_draft_only_lacks_a_model(sqlite_session: Session) -> None: + from services.app.console_gateway import _STARTER_DRAFT_GRAPHS + + issues = check_graph( + _STARTER_DRAFT_GRAPHS[AppMode.ADVANCED_CHAT], + mode=AppMode.ADVANCED_CHAT, + resources=credential_check("ws-1", {}, sqlite_session), + ) + + assert [(i.code, i.node_id, i.message) for i in issues] == [ + (IssueCode.RESOURCE_UNAVAILABLE, "llm", "Node llm (llm): Missing provider or model configuration") + ] diff --git a/api/tests/unit_tests/services/workflow/test_graph_diff.py b/api/tests/unit_tests/services/workflow/test_graph_diff.py new file mode 100644 index 00000000000..f56e71c5a2c --- /dev/null +++ b/api/tests/unit_tests/services/workflow/test_graph_diff.py @@ -0,0 +1,103 @@ +from collections.abc import Mapping, Sequence + +import pytest +from sqlalchemy.orm import Session + +from models.model import App, AppMode +from models.workflow import Workflow, WorkflowType +from services.workflow.graph_diff import WorkflowSnapshot, current_draft_token, diff_workflows, draft_token +from services.workflow.node_defaults import fill_graph + + +def _snap( + nodes: Sequence[Mapping[str, object]], + edges: Sequence[Mapping[str, object]] = (), + *, + features: Mapping[str, object] | None = None, + env: frozenset[str] = frozenset(), +) -> WorkflowSnapshot: + return WorkflowSnapshot( + graph={"nodes": list(nodes), "edges": list(edges)}, + features=features or {}, + environment_variable_names=env, + ) + + +A: dict[str, object] = {"id": "a", "position": {"x": 0}, "data": {"type": "llm", "title": "A", "prompt": "x"}} +B: dict[str, object] = {"id": "b", "data": {"type": "end", "title": "B"}} + + +def test_diff_without_published() -> None: + diff = diff_workflows(None, _snap([A])) + assert diff.published is False + assert [n.id for n in diff.nodes_added] == ["a"] + + +def test_layout_changes_are_ignored() -> None: + moved = {**A, "position": {"x": 99}, "width": 10, "selected": True} + assert diff_workflows(_snap([A]), _snap([moved])) == diff_workflows(_snap([A]), _snap([A])) + + +def test_added_removed_changed() -> None: + changed = {**A, "data": {"type": "llm", "title": "A", "prompt": "y"}} + diff = diff_workflows(_snap([A, B]), _snap([changed])) + assert [n.id for n in diff.nodes_removed] == ["b"] + assert [(n.id, n.fields) for n in diff.nodes_changed] == [("a", ["prompt"])] + + +def test_edges_features_and_env() -> None: + edge = {"id": "e", "source": "a", "target": "b", "sourceHandle": "source"} + diff = diff_workflows( + _snap([A, B], features={"x": 1}, env=frozenset({"K"})), + _snap([A, B], [edge], env=frozenset({"K", "J"})), + ) + assert diff.edges_added == ["a → b (source)"] + assert diff.features_changed is True + assert diff.env_added == ["J"] + + +def test_defaults_filled_by_import_are_not_changes() -> None: + start = {"id": "s", "data": {"type": "start", "title": "Start"}} + http = {"id": "h", "data": {"type": "http-request", "title": "H", "method": "get", "url": "https://x"}} + published = _snap([start, http]) + draft = WorkflowSnapshot(graph=fill_graph(published.graph), features={}, environment_variable_names=frozenset()) + assert diff_workflows(published, draft) == diff_workflows(published, published) + assert diff_workflows(draft, published) == diff_workflows(draft, draft) + + +def _workflow(version: str) -> Workflow: + return Workflow( + tenant_id="tenant-1", + app_id="app-1", + type=WorkflowType.WORKFLOW, + version=version, + graph='{"nodes": [], "edges": []}', + features="{}", + created_by="account-1", + ) + + +def test_current_draft_token_hashes_the_stored_draft(sqlite_session: Session) -> None: + sqlite_session.add( + App(id="app-1", tenant_id="tenant-1", name="a", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True) + ) + draft = _workflow(Workflow.VERSION_DRAFT) + sqlite_session.add_all([draft, _workflow("2026-10-14 00:00:00")]) + sqlite_session.commit() + + assert current_draft_token("tenant-1", "app-1") == draft_token(draft) + + +@pytest.mark.parametrize(("tenant_id", "with_draft"), [("tenant-1", False), ("tenant-2", True)]) +def test_current_draft_token_is_none_without_a_draft_in_the_workspace( + sqlite_session: Session, tenant_id: str, with_draft: bool +) -> None: + sqlite_session.add( + App(id="app-1", tenant_id="tenant-1", name="a", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True) + ) + if with_draft: + sqlite_session.add(_workflow(Workflow.VERSION_DRAFT)) + sqlite_session.commit() + + assert current_draft_token(tenant_id, "app-1") is None + assert current_draft_token(tenant_id, "missing-app") is None diff --git a/api/tests/unit_tests/services/workflow/test_node_defaults.py b/api/tests/unit_tests/services/workflow/test_node_defaults.py new file mode 100644 index 00000000000..eba9cd1db4f --- /dev/null +++ b/api/tests/unit_tests/services/workflow/test_node_defaults.py @@ -0,0 +1,102 @@ +from services.workflow.node_defaults import fill_graph, fill_node_data + + +def test_start_gets_empty_variables() -> None: + assert fill_node_data({"type": "start", "title": "Start"})["variables"] == [] + + +def test_http_body_gets_data() -> None: + assert fill_node_data({"type": "http-request", "body": {"type": "json"}})["body"]["data"] == [] + assert fill_node_data({"type": "http-request"})["body"] == {"type": "none", "data": []} + + +def test_tool_gets_empty_parameter_maps() -> None: + data = fill_node_data({"type": "tool"}) + assert (data["tool_parameters"], data["tool_configurations"]) == ({}, {}) + + +def test_existing_values_win() -> None: + data = fill_node_data({"type": "start", "variables": [{"variable": "q"}]}) + assert data["variables"] == [{"variable": "q"}] + assert fill_node_data({"type": "end", "outputs": []})["outputs"] == [] + + +def test_nothing_is_invented_for_required_choices() -> None: + data = fill_node_data({"type": "llm"}) + assert "model" not in data + assert "classes" not in fill_node_data({"type": "question-classifier"}) + + +def test_unknown_type_passes_through() -> None: + assert fill_node_data({"type": "future-node", "x": 1}) == {"type": "future-node", "x": 1} + + +def test_fill_graph_touches_every_node_and_keeps_edges() -> None: + graph = {"nodes": [{"id": "s", "data": {"type": "start"}}], "edges": [{"id": "e"}]} + filled = fill_graph(graph) + assert filled["nodes"][0]["data"]["variables"] == [] + assert filled["edges"] == [{"id": "e"}] + assert "variables" not in graph["nodes"][0]["data"] + + +def test_fill_graph_tolerates_bad_shapes() -> None: + assert fill_graph({"nodes": "x"}) == {"nodes": "x"} + assert fill_graph({"nodes": [{"id": "n"}]}) == {"nodes": [{"id": "n"}]} + + +_CONDITION = {"variable_selector": ["start", "q"], "comparison_operator": "is", "value": "x"} + + +def test_legacy_if_else_conditions_become_the_true_case() -> None: + data = fill_node_data({"type": "if-else", "logical_operator": "or", "conditions": [_CONDITION]}) + assert data["cases"] == [{"case_id": "true", "logical_operator": "or", "conditions": [_CONDITION]}] + assert data["conditions"] == [_CONDITION] + + +def test_legacy_if_else_without_operator_uses_and() -> None: + data = fill_node_data({"type": "if-else", "conditions": [_CONDITION]}) + assert data["cases"][0]["logical_operator"] == "and" + + +def test_explicit_if_else_cases_are_untouched() -> None: + cases = [{"case_id": "c1", "logical_operator": "and", "conditions": [_CONDITION]}] + data = fill_node_data({"type": "if-else", "cases": cases, "conditions": [_CONDITION]}) + assert data["cases"] == cases + + +def test_if_else_without_cases_or_conditions_gets_no_cases() -> None: + assert fill_node_data({"type": "if-else"})["cases"] == [] + + +# A validator that reads a field which failed validation raises KeyError, not ValidationError. +_UNKNOWN_AUTH: dict[str, object] = {"type": "oauth2", "config": {"type": "basic", "api_key": "k"}} + + +def _http(authorization: dict[str, object]) -> dict[str, object]: + return {"type": "http-request", "title": "H", "method": "get", "url": "https://x", "authorization": authorization} + + +def test_unexpected_validator_error_leaves_the_value_unchanged() -> None: + data = fill_node_data(_http(_UNKNOWN_AUTH)) + assert data["authorization"] == _UNKNOWN_AUTH + filled = fill_graph({"nodes": [{"id": "h", "data": _http(_UNKNOWN_AUTH)}]}) + assert filled["nodes"][0]["data"]["authorization"] == _UNKNOWN_AUTH + + +def test_list_items_get_their_model_defaults() -> None: + variable = {"variable": "q", "label": "Q", "type": "text-input"} + data = fill_node_data({"type": "start", "variables": [variable, "not-a-mapping"]}) + filled, kept = data["variables"] + assert (filled["required"], filled["options"]) == (False, []) + assert {k: filled[k] for k in variable} == variable + assert kept == "not-a-mapping" + + +def test_mapping_typed_fields_are_not_filled_as_their_value_model() -> None: + outputs = {"type": "string"} + assert fill_node_data({"type": "code", "outputs": outputs})["outputs"] == outputs + tool_parameters = {"q": {"type": "mixed", "value": "x"}} + assert fill_node_data({"type": "tool", "tool_parameters": tool_parameters})["tool_parameters"] == tool_parameters + datasource_parameters = {"q": {"type": "mixed", "value": "x"}} + data = fill_node_data({"type": "datasource", "datasource_parameters": datasource_parameters}) + assert data["datasource_parameters"] == datasource_parameters diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py index e52ce4ca02d..dca9ad866e2 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -23,7 +23,7 @@ from core.app.layers.pause_state_persist_layer import ( ) from core.workflow.human_input_policy import FormDisposition, HumanInputSurface from core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource -from core.workflow.nodes.human_input.enums import ValueSourceType +from core.workflow.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus from graphon.runtime import GraphRuntimeState, VariablePool @@ -319,10 +319,12 @@ def _persist_human_input_form( session_maker: sessionmaker[Session], *, recipients: Sequence[HumanInputFormRecipient] = (), + form_id: str = "form-1", + status: HumanInputFormStatus = HumanInputFormStatus.WAITING, ) -> datetime: expiration_time = datetime(2024, 1, 1) form = HumanInputForm( - id="form-1", + id=form_id, tenant_id="tenant-1", app_id="app-1", workflow_run_id="run-1", @@ -331,6 +333,7 @@ def _persist_human_input_form( form_definition='{"display_in_ui": true}', rendered_content="content", expiration_time=expiration_time, + status=status, ) with session_maker.begin() as session: session.add(form) @@ -1313,3 +1316,144 @@ def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_contex assert pause_event["event"] == StreamEvent.WORKFLOW_PAUSED assert pause_event["data"]["reasons"][0]["form_token"] == "wtok" assert pause_event["data"]["reasons"][0]["expiration_time"] == to_utc_timestamp(expiration_time) + + +def _human_input_reason(form_id: str) -> HumanInputRequired: + return HumanInputRequired( + form_id=form_id, + form_content="content", + node_id=f"node-{form_id}", + node_title="Human Input", + ) + + +def _build_paused_snapshot_events( + session_maker: sessionmaker[Session], + forms: Mapping[str, HumanInputFormStatus], + *, + pause_reason_form_ids: Sequence[str] | None = None, +) -> list[Mapping[str, Any]]: + for form_id, status in forms.items(): + _persist_human_input_form(session_maker, form_id=form_id, status=status) + reason_form_ids = list(forms) if pause_reason_form_ids is None else pause_reason_form_ids + pause_entity = _FakePauseEntity( + pause_id="pause-1", + workflow_run_id="run-1", + paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), + pause_reasons=[_human_input_reason(form_id) for form_id in reason_form_ids], + ) + return _build_snapshot_events( + workflow_run=_build_workflow_run(WorkflowExecutionStatus.PAUSED), + node_snapshots=[_build_snapshot(WorkflowNodeExecutionStatus.PAUSED)], + task_id="task-ctx", + message_context=None, + pause_entity=pause_entity, + resumption_context=_build_resumption_context("task-ctx"), + session_maker=session_maker, + ) + + +class _PausedRunRepo: + def __init__(self, pause_entity: WorkflowPauseEntity) -> None: + self._pause_entity = pause_entity + + def get_workflow_pause(self, _workflow_run_id: str) -> WorkflowPauseEntity: + return self._pause_entity + + +class _EmptyNodeRepo: + def get_execution_snapshots_by_workflow_run(self, **_kwargs: object) -> list[WorkflowNodeExecutionSnapshot]: + return [] + + +def _event_types(events: Sequence[Mapping[str, Any]]) -> list[str]: + return [event["event"] for event in events] + + +@pytest.mark.parametrize( + "closed_status", + [HumanInputFormStatus.SUBMITTED, HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.EXPIRED], +) +def test_snapshot_omits_forms_that_are_no_longer_waiting( + sqlite_session_factory: sessionmaker[Session], + closed_status: HumanInputFormStatus, +) -> None: + events = _build_paused_snapshot_events(sqlite_session_factory, {"form-1": closed_status}) + + assert StreamEvent.HUMAN_INPUT_REQUIRED not in _event_types(events) + assert StreamEvent.WORKFLOW_PAUSED not in _event_types(events) + + +def test_snapshot_omits_pause_when_form_row_is_missing(sqlite_session_factory: sessionmaker[Session]) -> None: + events = _build_paused_snapshot_events(sqlite_session_factory, {}, pause_reason_form_ids=["form-unknown"]) + + assert _event_types(events) == ["workflow_started", "node_started", "node_finished"] + + +def test_snapshot_replays_waiting_form_and_pause(sqlite_session_factory: sessionmaker[Session]) -> None: + events = _build_paused_snapshot_events(sqlite_session_factory, {"form-1": HumanInputFormStatus.WAITING}) + + assert _event_types(events)[-2:] == [StreamEvent.HUMAN_INPUT_REQUIRED, StreamEvent.WORKFLOW_PAUSED] + assert events[-2]["data"]["form_id"] == "form-1" + assert [reason["form_id"] for reason in events[-1]["data"]["reasons"]] == ["form-1"] + + +def test_snapshot_replays_only_waiting_forms_when_mixed(sqlite_session_factory: sessionmaker[Session]) -> None: + events = _build_paused_snapshot_events( + sqlite_session_factory, + {"form-1": HumanInputFormStatus.SUBMITTED, "form-2": HumanInputFormStatus.WAITING}, + ) + + human_input_events = [event for event in events if event["event"] == StreamEvent.HUMAN_INPUT_REQUIRED] + assert [event["data"]["form_id"] for event in human_input_events] == ["form-2"] + pause_data = events[-1]["data"] + assert events[-1]["event"] == StreamEvent.WORKFLOW_PAUSED + assert [reason["form_id"] for reason in pause_data["reasons"]] == ["form-2"] + assert pause_data["paused_nodes"] == ["node-form-2"] + + +def test_stream_continues_with_live_events_when_all_forms_submitted( + monkeypatch: pytest.MonkeyPatch, + sqlite_session_factory: sessionmaker[Session], +) -> None: + workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.PAUSED) + pause_entity = _FakePauseEntity( + pause_id="pause-1", + workflow_run_id="run-1", + paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), + pause_reasons=[_human_input_reason("form-1")], + ) + factory = service_module.DifyAPIRepositoryFactory + monkeypatch.setattr(factory, "create_api_workflow_run_repository", lambda _sm: _PausedRunRepo(pause_entity)) + monkeypatch.setattr(factory, "create_api_workflow_node_execution_repository", lambda _sm: _EmptyNodeRepo()) + topic = _Topic(_StaticSubscription()) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", lambda *_args: topic) + resumption_context = _build_resumption_context("task-1") + monkeypatch.setattr(service_module, "_load_resumption_context", lambda _pause_entity: resumption_context) + live_finished = {"event": StreamEvent.WORKFLOW_FINISHED.value, "task_id": "task-1"} + buffer_state = BufferState( + queue=queue.Queue(), + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + buffer_state.queue.put(live_finished) + monkeypatch.setattr(service_module, "_start_buffering", lambda _subscription: buffer_state) + _persist_human_input_form(sqlite_session_factory, status=HumanInputFormStatus.SUBMITTED) + + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=sqlite_session_factory, + close_on_pause=True, + ) + ) + + assert events[-1] == live_finished + snapshot_types = [event["event"] for event in events if isinstance(event, Mapping)] + assert StreamEvent.WORKFLOW_PAUSED not in snapshot_types + assert StreamEvent.HUMAN_INPUT_REQUIRED not in snapshot_types diff --git a/docker/envs/core-services/api.env.example b/docker/envs/core-services/api.env.example index 56dc72d1b19..f7272e50dd7 100644 --- a/docker/envs/core-services/api.env.example +++ b/docker/envs/core-services/api.env.example @@ -20,6 +20,9 @@ PASSWORD_PEPPER= # Client deadline for converting a Binding file to a ToolFile through the Agent backend. AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=240 +# Lifetime in days of difyctl (OAuth device flow) tokens, 1-365; out-of-range values are clamped +# OAUTH_TTL_DAYS=14 + # Cloudflare Turnstile server-side verification for Dify Cloud sign-in TURNSTILE_SECRET_KEY= TURNSTILE_ALLOWED_HOSTNAMES= diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 168a6e6ee05..c5c6f19cec7 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -4558,6 +4558,7 @@ export type PostAppsImportsByImportIdConfirmData = { export type PostAppsImportsByImportIdConfirmErrors = { 400: Import | ErrorResponse + 409: ErrorResponse default: ErrorResponse } diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts index dd0c7ee51e7..78c7f31e4b1 100644 --- a/packages/contracts/generated/api/openapi/orpc.gen.ts +++ b/packages/contracts/generated/api/openapi/orpc.gen.ts @@ -6,28 +6,81 @@ import { zDeleteAccountSessionsBySessionIdPath, zDeleteAccountSessionsBySessionIdResponse, zDeleteAccountSessionsSelfResponse, + zDeleteAppsByAppIdEnvByEnvIdPath, + zDeleteAppsByAppIdEnvByEnvIdResponse, zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath, zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse, + zDeleteWorkspacesByWorkspaceIdPluginsByPluginIdPath, + zDeleteWorkspacesByWorkspaceIdPluginsByPluginIdResponse, zGetAccountResponse, zGetAccountSessionsQuery, zGetAccountSessionsResponse, + zGetAppsByAppIdAppInfoAdvancedChatPath, + zGetAppsByAppIdAppInfoAdvancedChatResponse, + zGetAppsByAppIdAppInfoAgentChatPath, + zGetAppsByAppIdAppInfoAgentChatResponse, + zGetAppsByAppIdAppInfoAgentPath, + zGetAppsByAppIdAppInfoAgentResponse, + zGetAppsByAppIdAppInfoChatPath, + zGetAppsByAppIdAppInfoChatResponse, + zGetAppsByAppIdAppInfoCompletionPath, + zGetAppsByAppIdAppInfoCompletionResponse, + zGetAppsByAppIdAppInfoWorkflowPath, + zGetAppsByAppIdAppInfoWorkflowResponse, zGetAppsByAppIdDependenciesCheckPath, zGetAppsByAppIdDependenciesCheckResponse, zGetAppsByAppIdDslPath, zGetAppsByAppIdDslQuery, zGetAppsByAppIdDslResponse, + zGetAppsByAppIdEnvPath, + zGetAppsByAppIdEnvResponse, zGetAppsByAppIdHumanInputFormsByFormTokenPath, zGetAppsByAppIdHumanInputFormsByFormTokenResponse, zGetAppsByAppIdPath, zGetAppsByAppIdQuery, + zGetAppsByAppIdReleaseCheckPath, + zGetAppsByAppIdReleaseCheckResponse, zGetAppsByAppIdResponse, + zGetAppsByAppIdRunsByRunIdNodesPath, + zGetAppsByAppIdRunsByRunIdNodesResponse, + zGetAppsByAppIdRunsByRunIdPath, + zGetAppsByAppIdRunsByRunIdResponse, + zGetAppsByAppIdRunsPath, + zGetAppsByAppIdRunsQuery, + zGetAppsByAppIdRunsResponse, + zGetAppsByAppIdServiceApiAgentPath, + zGetAppsByAppIdServiceApiAgentResponse, + zGetAppsByAppIdServiceApiPath, + zGetAppsByAppIdServiceApiResponse, zGetAppsByAppIdTasksByTaskIdEventsPath, zGetAppsByAppIdTasksByTaskIdEventsQuery, zGetAppsByAppIdTasksByTaskIdEventsResponse, + zGetAppsByAppIdVersionsPath, + zGetAppsByAppIdVersionsQuery, + zGetAppsByAppIdVersionsResponse, + zGetAppsByAppIdWebappAccessAgentPath, + zGetAppsByAppIdWebappAccessAgentResponse, + zGetAppsByAppIdWebappAccessPath, + zGetAppsByAppIdWebappAccessResponse, + zGetAppsByAppIdWebappAdvancedChatPath, + zGetAppsByAppIdWebappAdvancedChatResponse, + zGetAppsByAppIdWebappAgentChatPath, + zGetAppsByAppIdWebappAgentChatResponse, + zGetAppsByAppIdWebappAgentPath, + zGetAppsByAppIdWebappAgentResponse, + zGetAppsByAppIdWebappChatPath, + zGetAppsByAppIdWebappChatResponse, + zGetAppsByAppIdWebappCompletionPath, + zGetAppsByAppIdWebappCompletionResponse, + zGetAppsByAppIdWebappWorkflowPath, + zGetAppsByAppIdWebappWorkflowResponse, zGetAppsQuery, zGetAppsResponse, zGetCatalogResponse, zGetHealthResponse, + zGetNodeTypesByNodeTypePath, + zGetNodeTypesByNodeTypeResponse, + zGetNodeTypesResponse, zGetOauthDeviceLookupQuery, zGetOauthDeviceLookupResponse, zGetPermittedExternalAppsByAppIdPath, @@ -36,16 +89,88 @@ import { zGetPermittedExternalAppsQuery, zGetPermittedExternalAppsResponse, zGetVersionResponse, + zGetWorkspacesByWorkspaceIdKnowledgeBasesPath, + zGetWorkspacesByWorkspaceIdKnowledgeBasesQuery, + zGetWorkspacesByWorkspaceIdKnowledgeBasesResponse, + zGetWorkspacesByWorkspaceIdMarketplacePluginsPath, + zGetWorkspacesByWorkspaceIdMarketplacePluginsQuery, + zGetWorkspacesByWorkspaceIdMarketplacePluginsResponse, zGetWorkspacesByWorkspaceIdMembersPath, zGetWorkspacesByWorkspaceIdMembersQuery, zGetWorkspacesByWorkspaceIdMembersResponse, + zGetWorkspacesByWorkspaceIdModelProvidersByProviderPath, + zGetWorkspacesByWorkspaceIdModelProvidersByProviderResponse, + zGetWorkspacesByWorkspaceIdModelsPath, + zGetWorkspacesByWorkspaceIdModelsQuery, + zGetWorkspacesByWorkspaceIdModelsResponse, zGetWorkspacesByWorkspaceIdPath, + zGetWorkspacesByWorkspaceIdPluginsPath, + zGetWorkspacesByWorkspaceIdPluginsQuery, + zGetWorkspacesByWorkspaceIdPluginsResponse, + zGetWorkspacesByWorkspaceIdPluginTasksByTaskIdPath, + zGetWorkspacesByWorkspaceIdPluginTasksByTaskIdResponse, zGetWorkspacesByWorkspaceIdResponse, + zGetWorkspacesByWorkspaceIdToolProvidersByProviderPath, + zGetWorkspacesByWorkspaceIdToolProvidersByProviderResponse, + zGetWorkspacesByWorkspaceIdToolsPath, + zGetWorkspacesByWorkspaceIdToolsQuery, + zGetWorkspacesByWorkspaceIdToolsResponse, zGetWorkspacesQuery, zGetWorkspacesResponse, + zPatchAppsByAppIdAppInfoAdvancedChatBody, + zPatchAppsByAppIdAppInfoAdvancedChatPath, + zPatchAppsByAppIdAppInfoAdvancedChatResponse, + zPatchAppsByAppIdAppInfoAgentBody, + zPatchAppsByAppIdAppInfoAgentChatBody, + zPatchAppsByAppIdAppInfoAgentChatPath, + zPatchAppsByAppIdAppInfoAgentChatResponse, + zPatchAppsByAppIdAppInfoAgentPath, + zPatchAppsByAppIdAppInfoAgentResponse, + zPatchAppsByAppIdAppInfoChatBody, + zPatchAppsByAppIdAppInfoChatPath, + zPatchAppsByAppIdAppInfoChatResponse, + zPatchAppsByAppIdAppInfoCompletionBody, + zPatchAppsByAppIdAppInfoCompletionPath, + zPatchAppsByAppIdAppInfoCompletionResponse, + zPatchAppsByAppIdAppInfoWorkflowBody, + zPatchAppsByAppIdAppInfoWorkflowPath, + zPatchAppsByAppIdAppInfoWorkflowResponse, + zPatchAppsByAppIdServiceApiAgentBody, + zPatchAppsByAppIdServiceApiAgentPath, + zPatchAppsByAppIdServiceApiAgentResponse, + zPatchAppsByAppIdServiceApiBody, + zPatchAppsByAppIdServiceApiPath, + zPatchAppsByAppIdServiceApiResponse, + zPatchAppsByAppIdWebappAdvancedChatBody, + zPatchAppsByAppIdWebappAdvancedChatPath, + zPatchAppsByAppIdWebappAdvancedChatResponse, + zPatchAppsByAppIdWebappAgentBody, + zPatchAppsByAppIdWebappAgentChatBody, + zPatchAppsByAppIdWebappAgentChatPath, + zPatchAppsByAppIdWebappAgentChatResponse, + zPatchAppsByAppIdWebappAgentPath, + zPatchAppsByAppIdWebappAgentResponse, + zPatchAppsByAppIdWebappChatBody, + zPatchAppsByAppIdWebappChatPath, + zPatchAppsByAppIdWebappChatResponse, + zPatchAppsByAppIdWebappCompletionBody, + zPatchAppsByAppIdWebappCompletionPath, + zPatchAppsByAppIdWebappCompletionResponse, + zPatchAppsByAppIdWebappWorkflowBody, + zPatchAppsByAppIdWebappWorkflowPath, + zPatchAppsByAppIdWebappWorkflowResponse, zPatchWorkspacesByWorkspaceIdMembersByMemberIdBody, zPatchWorkspacesByWorkspaceIdMembersByMemberIdPath, zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse, + zPatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdBody, + zPatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdPath, + zPatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdResponse, + zPatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdBody, + zPatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdPath, + zPatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdResponse, + zPatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdBody, + zPatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdPath, + zPatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdResponse, zPostAppsByAppIdAdvancedChatRunBody, zPostAppsByAppIdAdvancedChatRunPath, zPostAppsByAppIdAdvancedChatRunResponse, @@ -55,14 +180,35 @@ import { zPostAppsByAppIdCompletionRunBody, zPostAppsByAppIdCompletionRunPath, zPostAppsByAppIdCompletionRunResponse, + zPostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunBody, + zPostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunPath, + zPostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunResponse, + zPostAppsByAppIdDraftAdvancedChatRunBody, + zPostAppsByAppIdDraftAdvancedChatRunPath, + zPostAppsByAppIdDraftAdvancedChatRunResponse, + zPostAppsByAppIdDraftWorkflowNodesByNodeIdRunBody, + zPostAppsByAppIdDraftWorkflowNodesByNodeIdRunPath, + zPostAppsByAppIdDraftWorkflowNodesByNodeIdRunResponse, + zPostAppsByAppIdDraftWorkflowRunBody, + zPostAppsByAppIdDraftWorkflowRunPath, + zPostAppsByAppIdDraftWorkflowRunResponse, zPostAppsByAppIdFilesBody, zPostAppsByAppIdFilesPath, zPostAppsByAppIdFilesResponse, zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody, zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath, zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse, + zPostAppsByAppIdPublishBody, + zPostAppsByAppIdPublishPath, + zPostAppsByAppIdPublishResponse, zPostAppsByAppIdTasksByTaskIdStopPath, zPostAppsByAppIdTasksByTaskIdStopResponse, + zPostAppsByAppIdVersionsByVersionIdRestorePath, + zPostAppsByAppIdVersionsByVersionIdRestoreResponse, + zPostAppsByAppIdWebappAgentResetPath, + zPostAppsByAppIdWebappAgentResetResponse, + zPostAppsByAppIdWebappResetPath, + zPostAppsByAppIdWebappResetResponse, zPostAppsByAppIdWorkflowRunBody, zPostAppsByAppIdWorkflowRunPath, zPostAppsByAppIdWorkflowRunResponse, @@ -74,16 +220,44 @@ import { zPostOauthDeviceDenyResponse, zPostOauthDeviceTokenBody, zPostOauthDeviceTokenResponse, + zPostWorkspacesByWorkspaceIdAppsAdvancedChatBody, + zPostWorkspacesByWorkspaceIdAppsAdvancedChatPath, + zPostWorkspacesByWorkspaceIdAppsAdvancedChatResponse, zPostWorkspacesByWorkspaceIdAppsImportsBody, zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath, zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse, + zPostWorkspacesByWorkspaceIdAppsImportsCheckBody, + zPostWorkspacesByWorkspaceIdAppsImportsCheckPath, + zPostWorkspacesByWorkspaceIdAppsImportsCheckResponse, zPostWorkspacesByWorkspaceIdAppsImportsPath, zPostWorkspacesByWorkspaceIdAppsImportsResponse, + zPostWorkspacesByWorkspaceIdAppsWorkflowBody, + zPostWorkspacesByWorkspaceIdAppsWorkflowPath, + zPostWorkspacesByWorkspaceIdAppsWorkflowResponse, zPostWorkspacesByWorkspaceIdMembersBody, zPostWorkspacesByWorkspaceIdMembersPath, zPostWorkspacesByWorkspaceIdMembersResponse, - zPostWorkspacesByWorkspaceIdSwitchPath, - zPostWorkspacesByWorkspaceIdSwitchResponse, + zPostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsBody, + zPostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsPath, + zPostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsResponse, + zPostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsBody, + zPostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsPath, + zPostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsResponse, + zPostWorkspacesByWorkspaceIdPluginsInstallBody, + zPostWorkspacesByWorkspaceIdPluginsInstallPath, + zPostWorkspacesByWorkspaceIdPluginsInstallResponse, + zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsBody, + zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsPath, + zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsResponse, + zPutAppsByAppIdEnvByEnvIdBody, + zPutAppsByAppIdEnvByEnvIdPath, + zPutAppsByAppIdEnvByEnvIdResponse, + zPutAppsByAppIdWebappAccessAgentBody, + zPutAppsByAppIdWebappAccessAgentPath, + zPutAppsByAppIdWebappAccessAgentResponse, + zPutAppsByAppIdWebappAccessBody, + zPutAppsByAppIdWebappAccessPath, + zPutAppsByAppIdWebappAccessResponse, } from './zod.gen.ts' /** @@ -217,6 +391,204 @@ export const advancedChat = { run, } +export const get6 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAppInfoAdvancedChat', + path: '/apps/{app_id}/app-info/advanced-chat', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdAppInfoAdvancedChatPath })) + .output(zGetAppsByAppIdAppInfoAdvancedChatResponse) + +export const patch = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdAppInfoAdvancedChat', + path: '/apps/{app_id}/app-info/advanced-chat', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchAppsByAppIdAppInfoAdvancedChatBody, + params: zPatchAppsByAppIdAppInfoAdvancedChatPath, + }), + ) + .output(zPatchAppsByAppIdAppInfoAdvancedChatResponse) + +export const advancedChat2 = { + get: get6, + patch, +} + +export const get7 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAppInfoAgent', + path: '/apps/{app_id}/app-info/agent', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdAppInfoAgentPath })) + .output(zGetAppsByAppIdAppInfoAgentResponse) + +export const patch2 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdAppInfoAgent', + path: '/apps/{app_id}/app-info/agent', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchAppsByAppIdAppInfoAgentBody, + params: zPatchAppsByAppIdAppInfoAgentPath, + }), + ) + .output(zPatchAppsByAppIdAppInfoAgentResponse) + +export const agent = { + get: get7, + patch: patch2, +} + +export const get8 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAppInfoAgentChat', + path: '/apps/{app_id}/app-info/agent-chat', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdAppInfoAgentChatPath })) + .output(zGetAppsByAppIdAppInfoAgentChatResponse) + +export const patch3 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdAppInfoAgentChat', + path: '/apps/{app_id}/app-info/agent-chat', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchAppsByAppIdAppInfoAgentChatBody, + params: zPatchAppsByAppIdAppInfoAgentChatPath, + }), + ) + .output(zPatchAppsByAppIdAppInfoAgentChatResponse) + +export const agentChat = { + get: get8, + patch: patch3, +} + +export const get9 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAppInfoChat', + path: '/apps/{app_id}/app-info/chat', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdAppInfoChatPath })) + .output(zGetAppsByAppIdAppInfoChatResponse) + +export const patch4 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdAppInfoChat', + path: '/apps/{app_id}/app-info/chat', + tags: ['openapi'], + }) + .input( + z.object({ body: zPatchAppsByAppIdAppInfoChatBody, params: zPatchAppsByAppIdAppInfoChatPath }), + ) + .output(zPatchAppsByAppIdAppInfoChatResponse) + +export const chat = { + get: get9, + patch: patch4, +} + +export const get10 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAppInfoCompletion', + path: '/apps/{app_id}/app-info/completion', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdAppInfoCompletionPath })) + .output(zGetAppsByAppIdAppInfoCompletionResponse) + +export const patch5 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdAppInfoCompletion', + path: '/apps/{app_id}/app-info/completion', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchAppsByAppIdAppInfoCompletionBody, + params: zPatchAppsByAppIdAppInfoCompletionPath, + }), + ) + .output(zPatchAppsByAppIdAppInfoCompletionResponse) + +export const completion = { + get: get10, + patch: patch5, +} + +export const get11 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAppInfoWorkflow', + path: '/apps/{app_id}/app-info/workflow', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdAppInfoWorkflowPath })) + .output(zGetAppsByAppIdAppInfoWorkflowResponse) + +export const patch6 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdAppInfoWorkflow', + path: '/apps/{app_id}/app-info/workflow', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchAppsByAppIdAppInfoWorkflowBody, + params: zPatchAppsByAppIdAppInfoWorkflowPath, + }), + ) + .output(zPatchAppsByAppIdAppInfoWorkflowResponse) + +export const workflow = { + get: get11, + patch: patch6, +} + +export const appInfo = { + advancedChat: advancedChat2, + agent, + agentChat, + chat, + completion, + workflow, +} + export const post2 = oc .route({ inputStructure: 'detailed', @@ -232,7 +604,7 @@ export const run2 = { post: post2, } -export const chat = { +export const chat2 = { run: run2, } @@ -256,11 +628,11 @@ export const run3 = { post: post3, } -export const completion = { +export const completion2 = { run: run3, } -export const get6 = oc +export const get12 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -272,14 +644,125 @@ export const get6 = oc .output(zGetAppsByAppIdDependenciesCheckResponse) export const check = { - get: get6, + get: get12, } export const dependencies = { check, } -export const get7 = oc +export const post4 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdDraftAdvancedChatNodesByNodeIdRun', + path: '/apps/{app_id}/draft/advanced-chat/nodes/{node_id}:run', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunBody, + params: zPostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunPath, + }), + ) + .output(zPostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunResponse) + +export const run4 = { + post: post4, +} + +export const byNodeId = { + run: run4, +} + +export const nodes = { + byNodeId, +} + +export const post5 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdDraftAdvancedChatRun', + path: '/apps/{app_id}/draft/advanced-chat:run', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostAppsByAppIdDraftAdvancedChatRunBody, + params: zPostAppsByAppIdDraftAdvancedChatRunPath, + }), + ) + .output(zPostAppsByAppIdDraftAdvancedChatRunResponse) + +export const run5 = { + post: post5, +} + +export const advancedChat3 = { + nodes, + run: run5, +} + +export const post6 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdDraftWorkflowNodesByNodeIdRun', + path: '/apps/{app_id}/draft/workflow/nodes/{node_id}:run', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostAppsByAppIdDraftWorkflowNodesByNodeIdRunBody, + params: zPostAppsByAppIdDraftWorkflowNodesByNodeIdRunPath, + }), + ) + .output(zPostAppsByAppIdDraftWorkflowNodesByNodeIdRunResponse) + +export const run6 = { + post: post6, +} + +export const byNodeId2 = { + run: run6, +} + +export const nodes2 = { + byNodeId: byNodeId2, +} + +export const post7 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdDraftWorkflowRun', + path: '/apps/{app_id}/draft/workflow:run', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostAppsByAppIdDraftWorkflowRunBody, + params: zPostAppsByAppIdDraftWorkflowRunPath, + }), + ) + .output(zPostAppsByAppIdDraftWorkflowRunResponse) + +export const run7 = { + post: post7, +} + +export const workflow2 = { + nodes: nodes2, + run: run7, +} + +export const draft = { + advancedChat: advancedChat3, + workflow: workflow2, +} + +export const get13 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -291,13 +774,56 @@ export const get7 = oc .output(zGetAppsByAppIdDslResponse) export const dsl = { - get: get7, + get: get13, +} + +export const delete3 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAppsByAppIdEnvByEnvId', + path: '/apps/{app_id}/env/{env_id}', + tags: ['openapi'], + }) + .input(z.object({ params: zDeleteAppsByAppIdEnvByEnvIdPath })) + .output(zDeleteAppsByAppIdEnvByEnvIdResponse) + +export const put = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAppsByAppIdEnvByEnvId', + path: '/apps/{app_id}/env/{env_id}', + tags: ['openapi'], + }) + .input(z.object({ body: zPutAppsByAppIdEnvByEnvIdBody, params: zPutAppsByAppIdEnvByEnvIdPath })) + .output(zPutAppsByAppIdEnvByEnvIdResponse) + +export const byEnvId = { + delete: delete3, + put, +} + +export const get14 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdEnv', + path: '/apps/{app_id}/env', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdEnvPath })) + .output(zGetAppsByAppIdEnvResponse) + +export const env = { + get: get14, + byEnvId, } /** * Upload a file to use as an input variable when running the app */ -export const post4 = oc +export const post8 = oc .route({ description: 'Upload a file to use as an input variable when running the app', inputStructure: 'detailed', @@ -311,10 +837,10 @@ export const post4 = oc .output(zPostAppsByAppIdFilesResponse) export const files = { - post: post4, + post: post8, } -export const post5 = oc +export const post9 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -331,10 +857,10 @@ export const post5 = oc .output(zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse) export const submit = { - post: post5, + post: post9, } -export const get8 = oc +export const get15 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -346,7 +872,7 @@ export const get8 = oc .output(zGetAppsByAppIdHumanInputFormsByFormTokenResponse) export const byFormToken = { - get: get8, + get: get15, submit, } @@ -354,7 +880,135 @@ export const humanInputForms = { byFormToken, } -export const get9 = oc +export const get16 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdReleaseCheck', + path: '/apps/{app_id}/release:check', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdReleaseCheckPath })) + .output(zGetAppsByAppIdReleaseCheckResponse) + +export const check2 = { + get: get16, +} + +export const release = { + check: check2, +} + +export const get17 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdRunsByRunIdNodes', + path: '/apps/{app_id}/runs/{run_id}/nodes', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdRunsByRunIdNodesPath })) + .output(zGetAppsByAppIdRunsByRunIdNodesResponse) + +export const nodes3 = { + get: get17, +} + +export const get18 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdRunsByRunId', + path: '/apps/{app_id}/runs/{run_id}', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdRunsByRunIdPath })) + .output(zGetAppsByAppIdRunsByRunIdResponse) + +export const byRunId = { + get: get18, + nodes: nodes3, +} + +export const get19 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdRuns', + path: '/apps/{app_id}/runs', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdRunsPath, query: zGetAppsByAppIdRunsQuery.optional() })) + .output(zGetAppsByAppIdRunsResponse) + +export const runs = { + get: get19, + byRunId, +} + +export const get20 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdServiceApiAgent', + path: '/apps/{app_id}/service-api/agent', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdServiceApiAgentPath })) + .output(zGetAppsByAppIdServiceApiAgentResponse) + +export const patch7 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdServiceApiAgent', + path: '/apps/{app_id}/service-api/agent', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchAppsByAppIdServiceApiAgentBody, + params: zPatchAppsByAppIdServiceApiAgentPath, + }), + ) + .output(zPatchAppsByAppIdServiceApiAgentResponse) + +export const agent2 = { + get: get20, + patch: patch7, +} + +export const get21 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdServiceApi', + path: '/apps/{app_id}/service-api', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdServiceApiPath })) + .output(zGetAppsByAppIdServiceApiResponse) + +export const patch8 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdServiceApi', + path: '/apps/{app_id}/service-api', + tags: ['openapi'], + }) + .input( + z.object({ body: zPatchAppsByAppIdServiceApiBody, params: zPatchAppsByAppIdServiceApiPath }), + ) + .output(zPatchAppsByAppIdServiceApiResponse) + +export const serviceApi = { + get: get21, + patch: patch8, + agent: agent2, +} + +export const get22 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -371,10 +1025,10 @@ export const get9 = oc .output(zGetAppsByAppIdTasksByTaskIdEventsResponse) export const events = { - get: get9, + get: get22, } -export const post6 = oc +export const post10 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -386,7 +1040,7 @@ export const post6 = oc .output(zPostAppsByAppIdTasksByTaskIdStopResponse) export const stop = { - post: post6, + post: post10, } export const byTaskId = { @@ -398,7 +1052,336 @@ export const tasks = { byTaskId, } -export const post7 = oc +export const post11 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdVersionsByVersionIdRestore', + path: '/apps/{app_id}/versions/{version_id}:restore', + tags: ['openapi'], + }) + .input(z.object({ params: zPostAppsByAppIdVersionsByVersionIdRestorePath })) + .output(zPostAppsByAppIdVersionsByVersionIdRestoreResponse) + +export const restore = { + post: post11, +} + +export const byVersionId = { + restore, +} + +export const get23 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdVersions', + path: '/apps/{app_id}/versions', + tags: ['openapi'], + }) + .input( + z.object({ + params: zGetAppsByAppIdVersionsPath, + query: zGetAppsByAppIdVersionsQuery.optional(), + }), + ) + .output(zGetAppsByAppIdVersionsResponse) + +export const versions = { + get: get23, + byVersionId, +} + +export const get24 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdWebappAccessAgent', + path: '/apps/{app_id}/webapp-access/agent', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdWebappAccessAgentPath })) + .output(zGetAppsByAppIdWebappAccessAgentResponse) + +export const put2 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAppsByAppIdWebappAccessAgent', + path: '/apps/{app_id}/webapp-access/agent', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPutAppsByAppIdWebappAccessAgentBody, + params: zPutAppsByAppIdWebappAccessAgentPath, + }), + ) + .output(zPutAppsByAppIdWebappAccessAgentResponse) + +export const agent3 = { + get: get24, + put: put2, +} + +export const get25 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdWebappAccess', + path: '/apps/{app_id}/webapp-access', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdWebappAccessPath })) + .output(zGetAppsByAppIdWebappAccessResponse) + +export const put3 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAppsByAppIdWebappAccess', + path: '/apps/{app_id}/webapp-access', + tags: ['openapi'], + }) + .input( + z.object({ body: zPutAppsByAppIdWebappAccessBody, params: zPutAppsByAppIdWebappAccessPath }), + ) + .output(zPutAppsByAppIdWebappAccessResponse) + +export const webappAccess = { + get: get25, + put: put3, + agent: agent3, +} + +export const get26 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdWebappAdvancedChat', + path: '/apps/{app_id}/webapp/advanced-chat', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdWebappAdvancedChatPath })) + .output(zGetAppsByAppIdWebappAdvancedChatResponse) + +export const patch9 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdWebappAdvancedChat', + path: '/apps/{app_id}/webapp/advanced-chat', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchAppsByAppIdWebappAdvancedChatBody, + params: zPatchAppsByAppIdWebappAdvancedChatPath, + }), + ) + .output(zPatchAppsByAppIdWebappAdvancedChatResponse) + +export const advancedChat4 = { + get: get26, + patch: patch9, +} + +export const post12 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdWebappAgentReset', + path: '/apps/{app_id}/webapp/agent:reset', + tags: ['openapi'], + }) + .input(z.object({ params: zPostAppsByAppIdWebappAgentResetPath })) + .output(zPostAppsByAppIdWebappAgentResetResponse) + +export const reset = { + post: post12, +} + +export const get27 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdWebappAgent', + path: '/apps/{app_id}/webapp/agent', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdWebappAgentPath })) + .output(zGetAppsByAppIdWebappAgentResponse) + +export const patch10 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdWebappAgent', + path: '/apps/{app_id}/webapp/agent', + tags: ['openapi'], + }) + .input( + z.object({ body: zPatchAppsByAppIdWebappAgentBody, params: zPatchAppsByAppIdWebappAgentPath }), + ) + .output(zPatchAppsByAppIdWebappAgentResponse) + +export const agent4 = { + get: get27, + patch: patch10, + reset, +} + +export const get28 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdWebappAgentChat', + path: '/apps/{app_id}/webapp/agent-chat', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdWebappAgentChatPath })) + .output(zGetAppsByAppIdWebappAgentChatResponse) + +export const patch11 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdWebappAgentChat', + path: '/apps/{app_id}/webapp/agent-chat', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchAppsByAppIdWebappAgentChatBody, + params: zPatchAppsByAppIdWebappAgentChatPath, + }), + ) + .output(zPatchAppsByAppIdWebappAgentChatResponse) + +export const agentChat2 = { + get: get28, + patch: patch11, +} + +export const get29 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdWebappChat', + path: '/apps/{app_id}/webapp/chat', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdWebappChatPath })) + .output(zGetAppsByAppIdWebappChatResponse) + +export const patch12 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdWebappChat', + path: '/apps/{app_id}/webapp/chat', + tags: ['openapi'], + }) + .input( + z.object({ body: zPatchAppsByAppIdWebappChatBody, params: zPatchAppsByAppIdWebappChatPath }), + ) + .output(zPatchAppsByAppIdWebappChatResponse) + +export const chat3 = { + get: get29, + patch: patch12, +} + +export const get30 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdWebappCompletion', + path: '/apps/{app_id}/webapp/completion', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdWebappCompletionPath })) + .output(zGetAppsByAppIdWebappCompletionResponse) + +export const patch13 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdWebappCompletion', + path: '/apps/{app_id}/webapp/completion', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchAppsByAppIdWebappCompletionBody, + params: zPatchAppsByAppIdWebappCompletionPath, + }), + ) + .output(zPatchAppsByAppIdWebappCompletionResponse) + +export const completion3 = { + get: get30, + patch: patch13, +} + +export const get31 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdWebappWorkflow', + path: '/apps/{app_id}/webapp/workflow', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdWebappWorkflowPath })) + .output(zGetAppsByAppIdWebappWorkflowResponse) + +export const patch14 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchAppsByAppIdWebappWorkflow', + path: '/apps/{app_id}/webapp/workflow', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchAppsByAppIdWebappWorkflowBody, + params: zPatchAppsByAppIdWebappWorkflowPath, + }), + ) + .output(zPatchAppsByAppIdWebappWorkflowResponse) + +export const workflow3 = { + get: get31, + patch: patch14, +} + +export const post13 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdWebappReset', + path: '/apps/{app_id}/webapp:reset', + tags: ['openapi'], + }) + .input(z.object({ params: zPostAppsByAppIdWebappResetPath })) + .output(zPostAppsByAppIdWebappResetResponse) + +export const reset2 = { + post: post13, +} + +export const webapp = { + advancedChat: advancedChat4, + agent: agent4, + agentChat: agentChat2, + chat: chat3, + completion: completion3, + workflow: workflow3, + reset: reset2, +} + +export const post14 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -411,15 +1394,30 @@ export const post7 = oc ) .output(zPostAppsByAppIdWorkflowRunResponse) -export const run4 = { - post: post7, +export const run8 = { + post: post14, } -export const workflow = { - run: run4, +export const workflow4 = { + run: run8, } -export const get10 = oc +export const post15 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdPublish', + path: '/apps/{app_id}:publish', + tags: ['openapi'], + }) + .input(z.object({ body: zPostAppsByAppIdPublishBody, params: zPostAppsByAppIdPublishPath })) + .output(zPostAppsByAppIdPublishResponse) + +export const publish = { + post: post15, +} + +export const get32 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -431,19 +1429,29 @@ export const get10 = oc .output(zGetAppsByAppIdResponse) export const byAppId = { - get: get10, + get: get32, advancedChat, - chat, - completion, + appInfo, + chat: chat2, + completion: completion2, dependencies, + draft, dsl, + env, files, humanInputForms, + release, + runs, + serviceApi, tasks, - workflow, + versions, + webappAccess, + webapp, + workflow: workflow4, + publish, } -export const get11 = oc +export const get33 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -455,11 +1463,41 @@ export const get11 = oc .output(zGetAppsResponse) export const apps = { - get: get11, + get: get33, byAppId, } -export const post8 = oc +export const get34 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getNodeTypesByNodeType', + path: '/node-types/{node_type}', + tags: ['openapi'], + }) + .input(z.object({ params: zGetNodeTypesByNodeTypePath })) + .output(zGetNodeTypesByNodeTypeResponse) + +export const byNodeType = { + get: get34, +} + +export const get35 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getNodeTypes', + path: '/node-types', + tags: ['openapi'], + }) + .output(zGetNodeTypesResponse) + +export const nodeTypes = { + get: get35, + byNodeType, +} + +export const post16 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -471,10 +1509,10 @@ export const post8 = oc .output(zPostOauthDeviceApproveResponse) export const approve = { - post: post8, + post: post16, } -export const post9 = oc +export const post17 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -486,10 +1524,10 @@ export const post9 = oc .output(zPostOauthDeviceCodeResponse) export const code = { - post: post9, + post: post17, } -export const post10 = oc +export const post18 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -501,10 +1539,10 @@ export const post10 = oc .output(zPostOauthDeviceDenyResponse) export const deny = { - post: post10, + post: post18, } -export const get12 = oc +export const get36 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -516,10 +1554,10 @@ export const get12 = oc .output(zGetOauthDeviceLookupResponse) export const lookup = { - get: get12, + get: get36, } -export const post11 = oc +export const post19 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -531,7 +1569,7 @@ export const post11 = oc .output(zPostOauthDeviceTokenResponse) export const token = { - post: post11, + post: post19, } export const device = { @@ -546,7 +1584,7 @@ export const oauth = { device, } -export const get13 = oc +export const get37 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -563,10 +1601,10 @@ export const get13 = oc .output(zGetPermittedExternalAppsByAppIdResponse) export const byAppId2 = { - get: get13, + get: get37, } -export const get14 = oc +export const get38 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -578,11 +1616,32 @@ export const get14 = oc .output(zGetPermittedExternalAppsResponse) export const permittedExternalApps = { - get: get14, + get: get38, byAppId: byAppId2, } -export const post12 = oc +export const post20 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesByWorkspaceIdAppsAdvancedChat', + path: '/workspaces/{workspace_id}/apps/advanced-chat', + successStatus: 201, + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostWorkspacesByWorkspaceIdAppsAdvancedChatBody, + params: zPostWorkspacesByWorkspaceIdAppsAdvancedChatPath, + }), + ) + .output(zPostWorkspacesByWorkspaceIdAppsAdvancedChatResponse) + +export const advancedChat5 = { + post: post20, +} + +export const post21 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -594,14 +1653,34 @@ export const post12 = oc .output(zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse) export const confirm = { - post: post12, + post: post21, } export const byImportId = { confirm, } -export const post13 = oc +export const post22 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesByWorkspaceIdAppsImportsCheck', + path: '/workspaces/{workspace_id}/apps/imports:check', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostWorkspacesByWorkspaceIdAppsImportsCheckBody, + params: zPostWorkspacesByWorkspaceIdAppsImportsCheckPath, + }), + ) + .output(zPostWorkspacesByWorkspaceIdAppsImportsCheckResponse) + +export const check3 = { + post: post22, +} + +export const post23 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -618,15 +1697,83 @@ export const post13 = oc .output(zPostWorkspacesByWorkspaceIdAppsImportsResponse) export const imports = { - post: post13, + post: post23, byImportId, + check: check3, +} + +export const post24 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesByWorkspaceIdAppsWorkflow', + path: '/workspaces/{workspace_id}/apps/workflow', + successStatus: 201, + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostWorkspacesByWorkspaceIdAppsWorkflowBody, + params: zPostWorkspacesByWorkspaceIdAppsWorkflowPath, + }), + ) + .output(zPostWorkspacesByWorkspaceIdAppsWorkflowResponse) + +export const workflow5 = { + post: post24, } export const apps2 = { + advancedChat: advancedChat5, imports, + workflow: workflow5, } -export const delete3 = oc +export const get39 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesByWorkspaceIdKnowledgeBases', + path: '/workspaces/{workspace_id}/knowledge-bases', + tags: ['openapi'], + }) + .input( + z.object({ + params: zGetWorkspacesByWorkspaceIdKnowledgeBasesPath, + query: zGetWorkspacesByWorkspaceIdKnowledgeBasesQuery.optional(), + }), + ) + .output(zGetWorkspacesByWorkspaceIdKnowledgeBasesResponse) + +export const knowledgeBases = { + get: get39, +} + +export const get40 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesByWorkspaceIdMarketplacePlugins', + path: '/workspaces/{workspace_id}/marketplace/plugins', + tags: ['openapi'], + }) + .input( + z.object({ + params: zGetWorkspacesByWorkspaceIdMarketplacePluginsPath, + query: zGetWorkspacesByWorkspaceIdMarketplacePluginsQuery.optional(), + }), + ) + .output(zGetWorkspacesByWorkspaceIdMarketplacePluginsResponse) + +export const plugins = { + get: get40, +} + +export const marketplace = { + plugins, +} + +export const delete4 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -637,7 +1784,7 @@ export const delete3 = oc .input(z.object({ params: zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath })) .output(zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse) -export const patch = oc +export const patch15 = oc .route({ inputStructure: 'detailed', method: 'PATCH', @@ -654,11 +1801,11 @@ export const patch = oc .output(zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse) export const byMemberId = { - delete: delete3, - patch, + delete: delete4, + patch: patch15, } -export const get15 = oc +export const get41 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -674,7 +1821,7 @@ export const get15 = oc ) .output(zGetWorkspacesByWorkspaceIdMembersResponse) -export const post14 = oc +export const post25 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -692,27 +1839,303 @@ export const post14 = oc .output(zPostWorkspacesByWorkspaceIdMembersResponse) export const members = { - get: get15, - post: post14, + get: get41, + post: post25, byMemberId, } -export const post15 = oc +export const patch16 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialId', + path: '/workspaces/{workspace_id}/model-providers/{provider}/credentials/{credential_id}', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdBody, + params: zPatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdPath, + }), + ) + .output(zPatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdResponse) + +export const byCredentialId = { + patch: patch16, +} + +export const post26 = oc .route({ inputStructure: 'detailed', method: 'POST', - operationId: 'postWorkspacesByWorkspaceIdSwitch', - path: '/workspaces/{workspace_id}:switch', + operationId: 'postWorkspacesByWorkspaceIdModelProvidersByProviderCredentials', + path: '/workspaces/{workspace_id}/model-providers/{provider}/credentials', + successStatus: 201, tags: ['openapi'], }) - .input(z.object({ params: zPostWorkspacesByWorkspaceIdSwitchPath })) - .output(zPostWorkspacesByWorkspaceIdSwitchResponse) + .input( + z.object({ + body: zPostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsBody, + params: zPostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsPath, + }), + ) + .output(zPostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsResponse) -export const switch_ = { - post: post15, +export const credentials = { + post: post26, + byCredentialId, } -export const get16 = oc +export const patch17 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: + 'patchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialId', + path: '/workspaces/{workspace_id}/model-providers/{provider}/models/credentials/{credential_id}', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdBody, + params: + zPatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdPath, + }), + ) + .output( + zPatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdResponse, + ) + +export const byCredentialId2 = { + patch: patch17, +} + +export const post27 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentials', + path: '/workspaces/{workspace_id}/model-providers/{provider}/models/credentials', + successStatus: 201, + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsBody, + params: zPostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsPath, + }), + ) + .output(zPostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsResponse) + +export const credentials2 = { + post: post27, + byCredentialId: byCredentialId2, +} + +export const models = { + credentials: credentials2, +} + +export const get42 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesByWorkspaceIdModelProvidersByProvider', + path: '/workspaces/{workspace_id}/model-providers/{provider}', + tags: ['openapi'], + }) + .input(z.object({ params: zGetWorkspacesByWorkspaceIdModelProvidersByProviderPath })) + .output(zGetWorkspacesByWorkspaceIdModelProvidersByProviderResponse) + +export const byProvider = { + get: get42, + credentials, + models, +} + +export const modelProviders = { + byProvider, +} + +export const get43 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesByWorkspaceIdModels', + path: '/workspaces/{workspace_id}/models', + tags: ['openapi'], + }) + .input( + z.object({ + params: zGetWorkspacesByWorkspaceIdModelsPath, + query: zGetWorkspacesByWorkspaceIdModelsQuery.optional(), + }), + ) + .output(zGetWorkspacesByWorkspaceIdModelsResponse) + +export const models2 = { + get: get43, +} + +export const get44 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesByWorkspaceIdPluginTasksByTaskId', + path: '/workspaces/{workspace_id}/plugin-tasks/{task_id}', + tags: ['openapi'], + }) + .input(z.object({ params: zGetWorkspacesByWorkspaceIdPluginTasksByTaskIdPath })) + .output(zGetWorkspacesByWorkspaceIdPluginTasksByTaskIdResponse) + +export const byTaskId2 = { + get: get44, +} + +export const pluginTasks = { + byTaskId: byTaskId2, +} + +export const delete5 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesByWorkspaceIdPluginsByPluginId', + path: '/workspaces/{workspace_id}/plugins/{plugin_id}', + tags: ['openapi'], + }) + .input(z.object({ params: zDeleteWorkspacesByWorkspaceIdPluginsByPluginIdPath })) + .output(zDeleteWorkspacesByWorkspaceIdPluginsByPluginIdResponse) + +export const byPluginId = { + delete: delete5, +} + +export const post28 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesByWorkspaceIdPluginsInstall', + path: '/workspaces/{workspace_id}/plugins:install', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostWorkspacesByWorkspaceIdPluginsInstallBody, + params: zPostWorkspacesByWorkspaceIdPluginsInstallPath, + }), + ) + .output(zPostWorkspacesByWorkspaceIdPluginsInstallResponse) + +export const install = { + post: post28, +} + +export const get45 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesByWorkspaceIdPlugins', + path: '/workspaces/{workspace_id}/plugins', + tags: ['openapi'], + }) + .input( + z.object({ + params: zGetWorkspacesByWorkspaceIdPluginsPath, + query: zGetWorkspacesByWorkspaceIdPluginsQuery.optional(), + }), + ) + .output(zGetWorkspacesByWorkspaceIdPluginsResponse) + +export const plugins2 = { + get: get45, + byPluginId, + install, +} + +export const patch18 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialId', + path: '/workspaces/{workspace_id}/tool-providers/{provider}/credentials/{credential_id}', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdBody, + params: zPatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdPath, + }), + ) + .output(zPatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdResponse) + +export const byCredentialId3 = { + patch: patch18, +} + +export const post29 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesByWorkspaceIdToolProvidersByProviderCredentials', + path: '/workspaces/{workspace_id}/tool-providers/{provider}/credentials', + successStatus: 201, + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsBody, + params: zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsPath, + }), + ) + .output(zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsResponse) + +export const credentials3 = { + post: post29, + byCredentialId: byCredentialId3, +} + +export const get46 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesByWorkspaceIdToolProvidersByProvider', + path: '/workspaces/{workspace_id}/tool-providers/{provider}', + tags: ['openapi'], + }) + .input(z.object({ params: zGetWorkspacesByWorkspaceIdToolProvidersByProviderPath })) + .output(zGetWorkspacesByWorkspaceIdToolProvidersByProviderResponse) + +export const byProvider2 = { + get: get46, + credentials: credentials3, +} + +export const toolProviders = { + byProvider: byProvider2, +} + +export const get47 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesByWorkspaceIdTools', + path: '/workspaces/{workspace_id}/tools', + tags: ['openapi'], + }) + .input( + z.object({ + params: zGetWorkspacesByWorkspaceIdToolsPath, + query: zGetWorkspacesByWorkspaceIdToolsQuery.optional(), + }), + ) + .output(zGetWorkspacesByWorkspaceIdToolsResponse) + +export const tools = { + get: get47, +} + +export const get48 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -724,13 +2147,20 @@ export const get16 = oc .output(zGetWorkspacesByWorkspaceIdResponse) export const byWorkspaceId = { - get: get16, + get: get48, apps: apps2, + knowledgeBases, + marketplace, members, - switch: switch_, + modelProviders, + models: models2, + pluginTasks, + plugins: plugins2, + toolProviders, + tools, } -export const get17 = oc +export const get49 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -742,7 +2172,7 @@ export const get17 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get17, + get: get49, byWorkspaceId, } @@ -752,6 +2182,7 @@ export const contract = { version, account, apps, + nodeTypes, oauth, permittedExternalApps, workspaces, diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 4bc6577937b..6499d259e50 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -19,6 +19,16 @@ export type AccountResponse = { workspaces?: Array } +export type AdvancedChatNodeRunPayload = { + files?: { + [key: string]: Blob | File | Array + } | null + inputs?: { + [key: string]: unknown + } + query?: string +} + export type AdvancedChatRunPayload = { attachments?: Array | null auto_generate_name?: boolean @@ -26,7 +36,7 @@ export type AdvancedChatRunPayload = { files?: { [key: string]: Blob | File | Array } | null - inputs: { + inputs?: { [key: string]: unknown } query: string @@ -34,6 +44,96 @@ export type AdvancedChatRunPayload = { workspace_id?: string | null } +export type AdvancedChatWebApp = { + access_token?: string | null + app_base_url: string + chat_color_theme?: string | null + chat_color_theme_inverted?: boolean + copyright?: string | null + custom_disclaimer?: string | null + default_language?: string | null + description?: string | null + enabled: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null + input_placeholder?: string | null + privacy_policy?: string | null + show_workflow_steps?: boolean + title?: string | null + url?: string | null + use_icon_as_answer_icon?: boolean +} + +export type AdvancedChatWebAppPatch = { + chat_color_theme?: string | null + chat_color_theme_inverted?: boolean | null + copyright?: string | null + custom_disclaimer?: string | null + default_language?: string | null + description?: string | null + enabled?: boolean | null + icon?: string | null + icon_background?: string | null + icon_type?: 'emoji' | 'image' | 'link' | null + input_placeholder?: string | null + privacy_policy?: string | null + show_workflow_steps?: boolean | null + title?: string | null + use_icon_as_answer_icon?: boolean | null +} + +export type AgentAppInfo = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + max_active_requests?: number | null + name: string + role?: string | null + use_icon_as_answer_icon?: boolean +} + +export type AgentAppInfoPatch = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: 'emoji' | 'image' | 'link' | null + max_active_requests?: number | null + name?: string | null + role?: string | null + use_icon_as_answer_icon?: boolean | null +} + +export type AgentServiceApi = { + access_ready: boolean + api_rph?: number + api_rpm?: number + base_url: string + enabled: boolean +} + +export type AgentWebApp = { + access_ready: boolean + access_token?: string | null + app_base_url: string + chat_color_theme?: string | null + chat_color_theme_inverted?: boolean + copyright?: string | null + custom_disclaimer?: string | null + default_language?: string | null + description?: string | null + enabled: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null + input_placeholder?: string | null + privacy_policy?: string | null + title?: string | null + url?: string | null + use_icon_as_answer_icon?: boolean +} + export type AppDescribeInfo = { description?: string | null id: string @@ -49,6 +149,7 @@ export type AppDescribeQuery = { } export type AppDescribeResponse = { + hints?: Array info?: AppDescribeInfo | null input_schema?: { [key: string]: unknown @@ -65,11 +166,13 @@ export type AppDslExportQuery = { export type AppDslExportResponse = { data: string + draft_hash?: string | null } export type AppDslImportPayload = { app_id?: string | null description?: string | null + draft_hash?: string | null icon?: string | null icon_background?: string | null icon_type?: string | null @@ -99,6 +202,15 @@ export type AppInfo = { name: string } +export type AppInfoPatch = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: 'emoji' | 'image' | 'link' | null + max_active_requests?: number | null + name?: string | null +} + export type AppListQuery = { limit?: number mode?: SupportedAppType | null @@ -136,6 +248,35 @@ export type AppMode = | 'rag-pipeline' | 'workflow' +export type AppSettingsInfo = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + max_active_requests?: number | null + name: string +} + +export type ChatAppInfo = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + max_active_requests?: number | null + name: string + use_icon_as_answer_icon?: boolean +} + +export type ChatAppInfoPatch = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: 'emoji' | 'image' | 'link' | null + max_active_requests?: number | null + name?: string | null + use_icon_as_answer_icon?: boolean | null +} + export type ChatRunPayload = { attachments?: Array | null auto_generate_name?: boolean @@ -143,13 +284,55 @@ export type ChatRunPayload = { files?: { [key: string]: Blob | File | Array } | null - inputs: { + inputs?: { [key: string]: unknown } query: string workspace_id?: string | null } +export type ChatWebApp = { + access_token?: string | null + app_base_url: string + chat_color_theme?: string | null + chat_color_theme_inverted?: boolean + copyright?: string | null + custom_disclaimer?: string | null + default_language?: string | null + description?: string | null + enabled: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null + input_placeholder?: string | null + privacy_policy?: string | null + title?: string | null + url?: string | null + use_icon_as_answer_icon?: boolean +} + +export type ChatWebAppPatch = { + chat_color_theme?: string | null + chat_color_theme_inverted?: boolean | null + copyright?: string | null + custom_disclaimer?: string | null + default_language?: string | null + description?: string | null + enabled?: boolean | null + icon?: string | null + icon_background?: string | null + icon_type?: 'emoji' | 'image' | 'link' | null + input_placeholder?: string | null + privacy_policy?: string | null + title?: string | null + use_icon_as_answer_icon?: boolean | null +} + +export type CheckDependenciesResponse = { + hints?: Array + leaked_dependencies?: Array +} + export type CheckDependenciesResult = { leaked_dependencies?: Array } @@ -159,13 +342,57 @@ export type CompletionRunPayload = { files?: { [key: string]: Blob | File | Array } | null - inputs: { + inputs?: { [key: string]: unknown } query?: string workspace_id?: string | null } +export type CreateAppPayload = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: 'emoji' | 'image' | 'link' | null + name: string +} + +export type CreatedAppResponse = { + app_id: string + mode: string + name: string +} + +export type CredentialFormField = { + label: string | null + name: string + options: Array | null + placeholder: string | null + required: boolean + show_on: Array<{ + [key: string]: string + }> + type: string +} + +export type CredentialRef = { + id: string + name: string | null +} + +export type CredentialWriteResponse = { + active: boolean + hints?: Array + id: string + name: string | null +} + +export type CustomModelRow = { + active_credential: CredentialRef | null + model: string + model_type: string +} + export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE' export type DeviceCodeRequest = { @@ -230,6 +457,28 @@ export type DeviceTokenResponse = { workspaces?: Array } +export type DraftWorkflowRunPayload = { + attachments?: Array | null + files?: { + [key: string]: Blob | File | Array + } | null + inputs?: { + [key: string]: unknown + } + workspace_id?: string | null +} + +export type DslCheckPayload = { + app_id?: string | null + yaml_content: string +} + +export type DslCheckResponse = { + hints?: Array + issues: Array + valid: boolean +} + export type DslImportWarning = { code: string details?: { @@ -239,10 +488,40 @@ export type DslImportWarning = { path: string } +export type DslIssueRow = { + code: string + loc: Array + message: string + node_id: string | null + severity: string +} + +export type EnvVariableListResponse = { + data: Array +} + +export type EnvVariableRow = { + description?: string + id: string + name: string + value: unknown + value_type: string +} + +export type EnvVariableSetPayload = { + description?: string + name: string + value: unknown + value_type: EnvVariableValueType +} + +export type EnvVariableValueType = 'number' | 'secret' | 'string' + export type ErrorBody = { code: string details?: Array | null hint?: string | null + hints?: Array | null message: string status: number } @@ -278,7 +557,7 @@ export type FileUploadPayload = { } export type FormSubmitResponse = { - [key: string]: never + hints?: Array } export type Github = { @@ -333,11 +612,81 @@ export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | export type JsonValue = unknown +export type KnowledgeBaseListQuery = { + limit?: number + page?: number + query?: string +} + +export type KnowledgeBaseListResponse = { + data: Array + has_more: boolean + hints?: Array + limit: number + page: number + total: number +} + +export type KnowledgeBaseRow = { + description: string | null + document_count: number + id: string + indexing_technique: string | null + name: string + node_data: KnowledgeRetrievalFragment + provider: string + usable: boolean +} + +export type KnowledgeRetrievalFragment = { + dataset_ids: Array + multiple_retrieval_config?: MultipleRetrievalFragment + retrieval_mode?: 'multiple' +} + +export type LlmModelBlock = { + completion_params?: { + [key: string]: unknown + } + mode: string + name: string + provider: string +} + export type Marketplace = { marketplace_plugin_unique_identifier: string version?: string | null } +export type MarketplacePluginListResponse = { + data: Array + has_more: boolean + hints?: Array + limit: number + page: number + total: number +} + +export type MarketplacePluginQuery = { + category?: PluginCategory | null + limit?: number + page?: number + query?: string +} + +export type MarketplacePluginRow = { + authorized_category: string | null + brief: string | null + category: string + identifier: string + install_count: number + installed: boolean + installed_version: string | null + label: string | null + plugin_id: string + version: string +} + export type MemberActionResponse = { result?: 'success' } @@ -390,8 +739,124 @@ export type MessageMetadata = { usage?: UsageInfo | null } +export type ModelCredentialCreatePayload = { + credentials: { + [key: string]: unknown + } + model: string + model_type: ModelType + name?: string | null +} + +export type ModelCredentialUpdatePayload = { + credentials: { + [key: string]: unknown + } + model: string + model_type: ModelType + name?: string | null +} + +export type ModelListQuery = { + limit?: number + model_type?: ModelType | null + page?: number + provider?: string | null + query?: string +} + +export type ModelListResponse = { + data: Array + has_more: boolean + hints?: Array + limit: number + page: number + total: number +} + +export type ModelProviderDetailResponse = { + active_credential: CredentialRef | null + configured: boolean + credential_form: Array + credentials: Array + custom_model_form: Array | null + custom_models: Array + hints?: Array + label: string | null + model_types: Array + provider: string +} + +export type ModelProviderRow = { + active_credential: CredentialRef | null + configured: boolean + label: string | null + model_types: Array + provider: string +} + +export type ModelRef = { + model: string + model_type: ModelType +} + +export type ModelRow = { + features: Array + label: string | null + model: string + model_type: string + node_model?: LlmModelBlock | null + provider: string + provider_label: string | null + status: string +} + +export type ModelType = 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts' + +export type MultipleRetrievalFragment = { + reranking_enable?: boolean + top_k?: number +} + +export type NodeChange = { + fields: Array + id: string + title: string + type: string +} + +export type NodeRunPayload = { + files?: { + [key: string]: Blob | File | Array + } | null + inputs?: { + [key: string]: unknown + } +} + +export type NodeTypeDetailResponse = { + default_config: { + [key: string]: unknown + } + schema: { + [key: string]: unknown + } + type: string + version: string +} + +export type NodeTypeListResponse = { + data: Array +} + +export type NodeTypeRow = { + type: string + version: string +} + export type OpenApiErrorCode = | 'agent_not_published' + | 'app_mode_mismatch' | 'app_unavailable' | 'bad_gateway' | 'bad_request' @@ -399,6 +864,13 @@ export type OpenApiErrorCode = | 'completion_request_error' | 'conflict' | 'conversation_completed' + | 'credential_invalid' + | 'credential_not_found' + | 'credential_oauth_only' + | 'draft_changed' + | 'draft_not_found' + | 'dsl_invalid' + | 'env_variable_not_found' | 'file_extension_blocked' | 'file_too_large' | 'filename_not_exists' @@ -406,18 +878,30 @@ export type OpenApiErrorCode = | 'form_not_found' | 'internal_server_error' | 'invalid_param' + | 'marketplace_disabled' + | 'marketplace_unavailable' | 'member_license_exceeded' | 'member_limit_exceeded' | 'method_not_allowed' | 'model_currently_not_support' | 'no_file_uploaded' + | 'node_not_found' + | 'node_type_not_found' | 'not_acceptable' | 'not_found' + | 'plugin_install_forbidden' + | 'plugin_not_installed' + | 'plugin_service_unavailable' + | 'provider_not_found' | 'provider_not_initialize' | 'provider_quota_exceeded' | 'rate_limit_error' | 'recipient_surface_mismatch' | 'request_entity_too_large' + | 'run_not_found' + | 'secret_mask_not_secret' + | 'secret_mask_unknown_id' + | 'service_api_disabled' | 'too_many_files' | 'too_many_requests' | 'trigger_workflow_service_mode_unavailable' @@ -425,6 +909,12 @@ export type OpenApiErrorCode = | 'unknown' | 'unsupported_file_type' | 'unsupported_media_type' + | 'version_not_found' + | 'version_not_restorable' + | 'webapp_access_mode_console_only' + | 'webapp_access_requires_ee' + | 'webapp_access_unavailable' + | 'webapp_public_access_disabled' export type OpenApiFormSubmitPayload = { action: string @@ -457,6 +947,19 @@ export type PermittedExternalAppsListResponse = { total: number } +export type PluginCategory = + | 'agent-strategy' + | 'datasource' + | 'extension' + | 'model' + | 'tool' + | 'trigger' + +export type PluginDeleteResponse = { + deleted: boolean + plugin_id: string +} + export type PluginDependency = { current_identifier?: string | null type: PluginDependencyType @@ -465,15 +968,141 @@ export type PluginDependency = { export type PluginDependencyType = 'github' | 'marketplace' | 'package' +export type PluginInstallPayload = { + identifiers: Array +} + +export type PluginInstallTaskStatus = 'failed' | 'pending' | 'running' | 'success' + +export type PluginListQuery = { + category?: PluginCategory | null +} + +export type PluginListResponse = { + data: Array + hints?: Array +} + +export type PluginProvides = { + model_provider: string | null + tool_provider: string | null +} + +export type PluginRow = { + category: string + identifier: string + label: string | null + latest_version: string | null + plugin_id: string + provides: PluginProvides + source: string + version: string +} + +export type PluginTaskItem = { + identifier: string + message: string + plugin_id: string + status: PluginInstallTaskStatus +} + +export type PluginTaskResponse = { + hints?: Array + plugins: Array + status: PluginInstallTaskStatus + task_id: string +} + +export type PluginTaskStartResponse = { + all_installed: boolean + hints?: Array + task_id: string +} + +export type ProviderCredentialCreatePayload = { + credentials: { + [key: string]: unknown + } + name?: string | null +} + +export type ProviderCredentialUpdatePayload = { + credentials: { + [key: string]: unknown + } + name?: string | null +} + +export type PublishPayload = { + marked_comment?: string + marked_name?: string +} + +export type PublishResponse = { + created_at: number + version_id: string + warning?: string | null +} + +export type ReleaseCheckName = 'draft_valid' | 'tested' + +export type ReleaseCheckResponse = { + changes: WorkflowDiff + checks: Array + hints?: Array + issues: Array + ready: boolean + state: ReleaseState +} + +export type ReleaseCheckRow = { + detail: string + name: ReleaseCheckName + passed: boolean +} + +export type ReleaseState = { + service_api_enabled: boolean + webapp_enabled: boolean +} + +export type RestoreResponse = { + draft_hash: string + result: 'success' +} + export type RevokeResponse = { status: string } +export type RunListQuery = { + last_id?: string | null + limit?: number + status?: 'failed' | 'partial-succeeded' | 'running' | 'stopped' | 'succeeded' | null + triggered_from?: 'app-run' | 'debugging' +} + +export type RunListResponse = { + data: Array + has_more: boolean + hints?: Array + limit: number +} + export type ServerVersionResponse = { edition: DeploymentEdition version: string } +export type ServiceApi = { + base_url: string + enabled: boolean +} + +export type ServiceApiPatch = { + enabled: boolean +} + export type SessionListQuery = { limit?: number page?: number @@ -498,6 +1127,19 @@ export type SessionRow = { prefix: string } +export type SimpleAccountResponse = { + email: string + id: string + name: string +} + +export type SimpleEndUser = { + id: string + is_anonymous: boolean + session_id?: string | null + type: string +} + export type SimpleResultResponse = { result: string } @@ -510,12 +1152,194 @@ export type TaskStopResponse = { result: 'success' } +export type ToolCredentialCreatePayload = { + credentials: { + [key: string]: unknown + } + name?: string | null +} + +export type ToolCredentialUpdatePayload = { + credentials: { + [key: string]: unknown + } + name?: string | null +} + +export type ToolInputType = 'constant' | 'mixed' | 'variable' + +export type ToolInputValue = { + type?: ToolInputType + value?: unknown +} + +export type ToolListQuery = { + limit?: number + page?: number + provider?: string | null + provider_type?: ToolSource | null + query?: string +} + +export type ToolListResponse = { + data: Array + has_more: boolean + hints?: Array + limit: number + page: number + total: number +} + +export type ToolNodeTemplate = { + plugin_id: string | null + plugin_unique_identifier: string | null + provider_id: string + provider_name: string + provider_type: string + title: string + tool_configurations: { + [key: string]: ToolInputValue + } + tool_label: string + tool_name: string + tool_node_version?: string + tool_parameters: { + [key: string]: ToolInputValue + } + type?: string +} + +export type ToolParameterRow = { + default: unknown + form: string + label: string | null + llm_description: string | null + max: number | null + min: number | null + name: string + options: Array + required: boolean + type: string +} + +export type ToolProviderDetailResponse = { + configured: boolean + credential_form: Array + credential_types: Array + credentials: Array + default_credential: CredentialRef | null + hints?: Array + label: string | null + provider: string +} + +export type ToolProviderRow = { + configured: boolean + credential_types: Array + label: string | null + provider: string +} + +export type ToolRow = { + configured: boolean + description: string | null + label: string + name: string + node_data: ToolNodeTemplate + parameters: Array + provider: string + provider_label: string | null + provider_type: string +} + +export type ToolSource = 'api' | 'builtin' | 'mcp' | 'workflow' + export type UsageInfo = { completion_tokens?: number prompt_tokens?: number total_tokens?: number } +export type VersionListQuery = { + limit?: number + named_only?: boolean + page?: number +} + +export type VersionListResponse = { + data: Array + has_more: boolean + hints?: Array + limit: number + page: number +} + +export type VersionRow = { + created_at: number + created_by?: string | null + current?: boolean + id: string + marked_comment: string + marked_name: string +} + +export type WebApp = { + access_token?: string | null + app_base_url: string + copyright?: string | null + custom_disclaimer?: string | null + default_language?: string | null + description?: string | null + enabled: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null + privacy_policy?: string | null + title?: string | null + url?: string | null +} + +export type WebAppAccess = { + access_mode: string +} + +export type WebAppAccessMode = 'private' | 'private_all' | 'public' | 'sso_verified' + +export type WebAppAccessPayload = { + access_mode: WebAppAccessMode +} + +export type WebAppPatch = { + copyright?: string | null + custom_disclaimer?: string | null + default_language?: string | null + description?: string | null + enabled?: boolean | null + icon?: string | null + icon_background?: string | null + icon_type?: 'emoji' | 'image' | 'link' | null + privacy_policy?: string | null + title?: string | null +} + +export type WebAppToken = { + access_token?: string | null + app_base_url: string + url?: string | null +} + +export type WorkflowDiff = { + edges_added: Array + edges_removed: Array + env_added: Array + env_removed: Array + features_changed: boolean + nodes_added: Array + nodes_changed: Array + nodes_removed: Array + published: boolean +} + export type WorkflowRunData = { created_at?: number | null elapsed_time?: number | null @@ -531,21 +1355,114 @@ export type WorkflowRunData = { workflow_id: string } +export type WorkflowRunDetailResponse = { + created_at?: number | null + created_by_account?: SimpleAccountResponse | null + created_by_end_user?: SimpleEndUser | null + created_by_role?: string | null + elapsed_time?: number | null + error?: string | null + exceptions_count?: number | null + finished_at?: number | null + graph: unknown + id: string + inputs: unknown + outputs: unknown + status?: string | null + total_steps?: number | null + total_tokens?: number | null + version?: string | null +} + +export type WorkflowRunForListResponse = { + created_at?: number | null + created_by_account?: SimpleAccountResponse | null + elapsed_time?: number | null + exceptions_count?: number | null + finished_at?: number | null + id: string + retry_index?: number | null + status?: string | null + total_steps?: number | null + total_tokens?: number | null + version?: string | null +} + +export type WorkflowRunNodeExecutionListResponse = { + data: Array +} + +export type WorkflowRunNodeExecutionResponse = { + created_at?: number | null + created_by_account?: SimpleAccountResponse | null + created_by_end_user?: SimpleEndUser | null + created_by_role?: string | null + elapsed_time?: number | null + error?: string | null + execution_metadata?: unknown + extras?: unknown + finished_at?: number | null + id: string + index?: number | null + inputs?: unknown + inputs_truncated?: boolean | null + node_id?: string | null + node_type?: string | null + outputs?: unknown + outputs_truncated?: boolean | null + predecessor_node_id?: string | null + process_data?: unknown + process_data_truncated?: boolean | null + retry_index?: number | null + status?: string | null + title?: string | null +} + export type WorkflowRunPayload = { attachments?: Array | null files?: { [key: string]: Blob | File | Array } | null - inputs: { + inputs?: { [key: string]: unknown } workflow_id?: string | null workspace_id?: string | null } +export type WorkflowWebApp = { + access_token?: string | null + app_base_url: string + copyright?: string | null + custom_disclaimer?: string | null + default_language?: string | null + description?: string | null + enabled: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null + privacy_policy?: string | null + show_workflow_steps?: boolean + title?: string | null + url?: string | null +} + +export type WorkflowWebAppPatch = { + copyright?: string | null + custom_disclaimer?: string | null + default_language?: string | null + description?: string | null + enabled?: boolean | null + icon?: string | null + icon_background?: string | null + icon_type?: 'emoji' | 'image' | 'link' | null + privacy_policy?: string | null + show_workflow_steps?: boolean | null + title?: string | null +} + export type WorkspaceDetailResponse = { created_at?: string | null - current: boolean id: string name: string role: string @@ -573,7 +1490,6 @@ export type WorkspacePayload = { } export type WorkspaceSummaryResponse = { - current: boolean id: string name: string role: string @@ -800,6 +1716,288 @@ export type PostAppsByAppIdAdvancedChatRunResponses = { export type PostAppsByAppIdAdvancedChatRunResponse = PostAppsByAppIdAdvancedChatRunResponses[keyof PostAppsByAppIdAdvancedChatRunResponses] +export type GetAppsByAppIdAppInfoAdvancedChatData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/advanced-chat' +} + +export type GetAppsByAppIdAppInfoAdvancedChatErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdAppInfoAdvancedChatError = + GetAppsByAppIdAppInfoAdvancedChatErrors[keyof GetAppsByAppIdAppInfoAdvancedChatErrors] + +export type GetAppsByAppIdAppInfoAdvancedChatResponses = { + 200: ChatAppInfo +} + +export type GetAppsByAppIdAppInfoAdvancedChatResponse = + GetAppsByAppIdAppInfoAdvancedChatResponses[keyof GetAppsByAppIdAppInfoAdvancedChatResponses] + +export type PatchAppsByAppIdAppInfoAdvancedChatData = { + body: ChatAppInfoPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/advanced-chat' +} + +export type PatchAppsByAppIdAppInfoAdvancedChatErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdAppInfoAdvancedChatError = + PatchAppsByAppIdAppInfoAdvancedChatErrors[keyof PatchAppsByAppIdAppInfoAdvancedChatErrors] + +export type PatchAppsByAppIdAppInfoAdvancedChatResponses = { + 200: ChatAppInfo +} + +export type PatchAppsByAppIdAppInfoAdvancedChatResponse = + PatchAppsByAppIdAppInfoAdvancedChatResponses[keyof PatchAppsByAppIdAppInfoAdvancedChatResponses] + +export type GetAppsByAppIdAppInfoAgentData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/agent' +} + +export type GetAppsByAppIdAppInfoAgentErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdAppInfoAgentError = + GetAppsByAppIdAppInfoAgentErrors[keyof GetAppsByAppIdAppInfoAgentErrors] + +export type GetAppsByAppIdAppInfoAgentResponses = { + 200: AgentAppInfo +} + +export type GetAppsByAppIdAppInfoAgentResponse = + GetAppsByAppIdAppInfoAgentResponses[keyof GetAppsByAppIdAppInfoAgentResponses] + +export type PatchAppsByAppIdAppInfoAgentData = { + body: AgentAppInfoPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/agent' +} + +export type PatchAppsByAppIdAppInfoAgentErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdAppInfoAgentError = + PatchAppsByAppIdAppInfoAgentErrors[keyof PatchAppsByAppIdAppInfoAgentErrors] + +export type PatchAppsByAppIdAppInfoAgentResponses = { + 200: AgentAppInfo +} + +export type PatchAppsByAppIdAppInfoAgentResponse = + PatchAppsByAppIdAppInfoAgentResponses[keyof PatchAppsByAppIdAppInfoAgentResponses] + +export type GetAppsByAppIdAppInfoAgentChatData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/agent-chat' +} + +export type GetAppsByAppIdAppInfoAgentChatErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdAppInfoAgentChatError = + GetAppsByAppIdAppInfoAgentChatErrors[keyof GetAppsByAppIdAppInfoAgentChatErrors] + +export type GetAppsByAppIdAppInfoAgentChatResponses = { + 200: ChatAppInfo +} + +export type GetAppsByAppIdAppInfoAgentChatResponse = + GetAppsByAppIdAppInfoAgentChatResponses[keyof GetAppsByAppIdAppInfoAgentChatResponses] + +export type PatchAppsByAppIdAppInfoAgentChatData = { + body: ChatAppInfoPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/agent-chat' +} + +export type PatchAppsByAppIdAppInfoAgentChatErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdAppInfoAgentChatError = + PatchAppsByAppIdAppInfoAgentChatErrors[keyof PatchAppsByAppIdAppInfoAgentChatErrors] + +export type PatchAppsByAppIdAppInfoAgentChatResponses = { + 200: ChatAppInfo +} + +export type PatchAppsByAppIdAppInfoAgentChatResponse = + PatchAppsByAppIdAppInfoAgentChatResponses[keyof PatchAppsByAppIdAppInfoAgentChatResponses] + +export type GetAppsByAppIdAppInfoChatData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/chat' +} + +export type GetAppsByAppIdAppInfoChatErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdAppInfoChatError = + GetAppsByAppIdAppInfoChatErrors[keyof GetAppsByAppIdAppInfoChatErrors] + +export type GetAppsByAppIdAppInfoChatResponses = { + 200: ChatAppInfo +} + +export type GetAppsByAppIdAppInfoChatResponse = + GetAppsByAppIdAppInfoChatResponses[keyof GetAppsByAppIdAppInfoChatResponses] + +export type PatchAppsByAppIdAppInfoChatData = { + body: ChatAppInfoPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/chat' +} + +export type PatchAppsByAppIdAppInfoChatErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdAppInfoChatError = + PatchAppsByAppIdAppInfoChatErrors[keyof PatchAppsByAppIdAppInfoChatErrors] + +export type PatchAppsByAppIdAppInfoChatResponses = { + 200: ChatAppInfo +} + +export type PatchAppsByAppIdAppInfoChatResponse = + PatchAppsByAppIdAppInfoChatResponses[keyof PatchAppsByAppIdAppInfoChatResponses] + +export type GetAppsByAppIdAppInfoCompletionData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/completion' +} + +export type GetAppsByAppIdAppInfoCompletionErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdAppInfoCompletionError = + GetAppsByAppIdAppInfoCompletionErrors[keyof GetAppsByAppIdAppInfoCompletionErrors] + +export type GetAppsByAppIdAppInfoCompletionResponses = { + 200: AppSettingsInfo +} + +export type GetAppsByAppIdAppInfoCompletionResponse = + GetAppsByAppIdAppInfoCompletionResponses[keyof GetAppsByAppIdAppInfoCompletionResponses] + +export type PatchAppsByAppIdAppInfoCompletionData = { + body: AppInfoPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/completion' +} + +export type PatchAppsByAppIdAppInfoCompletionErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdAppInfoCompletionError = + PatchAppsByAppIdAppInfoCompletionErrors[keyof PatchAppsByAppIdAppInfoCompletionErrors] + +export type PatchAppsByAppIdAppInfoCompletionResponses = { + 200: AppSettingsInfo +} + +export type PatchAppsByAppIdAppInfoCompletionResponse = + PatchAppsByAppIdAppInfoCompletionResponses[keyof PatchAppsByAppIdAppInfoCompletionResponses] + +export type GetAppsByAppIdAppInfoWorkflowData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/workflow' +} + +export type GetAppsByAppIdAppInfoWorkflowErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdAppInfoWorkflowError = + GetAppsByAppIdAppInfoWorkflowErrors[keyof GetAppsByAppIdAppInfoWorkflowErrors] + +export type GetAppsByAppIdAppInfoWorkflowResponses = { + 200: AppSettingsInfo +} + +export type GetAppsByAppIdAppInfoWorkflowResponse = + GetAppsByAppIdAppInfoWorkflowResponses[keyof GetAppsByAppIdAppInfoWorkflowResponses] + +export type PatchAppsByAppIdAppInfoWorkflowData = { + body: AppInfoPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/app-info/workflow' +} + +export type PatchAppsByAppIdAppInfoWorkflowErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdAppInfoWorkflowError = + PatchAppsByAppIdAppInfoWorkflowErrors[keyof PatchAppsByAppIdAppInfoWorkflowErrors] + +export type PatchAppsByAppIdAppInfoWorkflowResponses = { + 200: AppSettingsInfo +} + +export type PatchAppsByAppIdAppInfoWorkflowResponse = + PatchAppsByAppIdAppInfoWorkflowResponses[keyof PatchAppsByAppIdAppInfoWorkflowResponses] + export type PostAppsByAppIdChatRunData = { body: ChatRunPayload path: { @@ -865,12 +2063,110 @@ export type GetAppsByAppIdDependenciesCheckError = GetAppsByAppIdDependenciesCheckErrors[keyof GetAppsByAppIdDependenciesCheckErrors] export type GetAppsByAppIdDependenciesCheckResponses = { - 200: CheckDependenciesResult + 200: CheckDependenciesResponse } export type GetAppsByAppIdDependenciesCheckResponse = GetAppsByAppIdDependenciesCheckResponses[keyof GetAppsByAppIdDependenciesCheckResponses] +export type PostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunData = { + body: AdvancedChatNodeRunPayload + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/draft/advanced-chat/nodes/{node_id}:run' +} + +export type PostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunError = + PostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunErrors[keyof PostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunErrors] + +export type PostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunResponses = { + 200: WorkflowRunNodeExecutionResponse +} + +export type PostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunResponse = + PostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunResponses[keyof PostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunResponses] + +export type PostAppsByAppIdDraftAdvancedChatRunData = { + body: ChatRunPayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/draft/advanced-chat:run' +} + +export type PostAppsByAppIdDraftAdvancedChatRunErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostAppsByAppIdDraftAdvancedChatRunError = + PostAppsByAppIdDraftAdvancedChatRunErrors[keyof PostAppsByAppIdDraftAdvancedChatRunErrors] + +export type PostAppsByAppIdDraftAdvancedChatRunResponses = { + 200: EventStreamResponse +} + +export type PostAppsByAppIdDraftAdvancedChatRunResponse = + PostAppsByAppIdDraftAdvancedChatRunResponses[keyof PostAppsByAppIdDraftAdvancedChatRunResponses] + +export type PostAppsByAppIdDraftWorkflowNodesByNodeIdRunData = { + body: NodeRunPayload + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/draft/workflow/nodes/{node_id}:run' +} + +export type PostAppsByAppIdDraftWorkflowNodesByNodeIdRunErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostAppsByAppIdDraftWorkflowNodesByNodeIdRunError = + PostAppsByAppIdDraftWorkflowNodesByNodeIdRunErrors[keyof PostAppsByAppIdDraftWorkflowNodesByNodeIdRunErrors] + +export type PostAppsByAppIdDraftWorkflowNodesByNodeIdRunResponses = { + 200: WorkflowRunNodeExecutionResponse +} + +export type PostAppsByAppIdDraftWorkflowNodesByNodeIdRunResponse = + PostAppsByAppIdDraftWorkflowNodesByNodeIdRunResponses[keyof PostAppsByAppIdDraftWorkflowNodesByNodeIdRunResponses] + +export type PostAppsByAppIdDraftWorkflowRunData = { + body: DraftWorkflowRunPayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/draft/workflow:run' +} + +export type PostAppsByAppIdDraftWorkflowRunErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostAppsByAppIdDraftWorkflowRunError = + PostAppsByAppIdDraftWorkflowRunErrors[keyof PostAppsByAppIdDraftWorkflowRunErrors] + +export type PostAppsByAppIdDraftWorkflowRunResponses = { + 200: EventStreamResponse +} + +export type PostAppsByAppIdDraftWorkflowRunResponse = + PostAppsByAppIdDraftWorkflowRunResponses[keyof PostAppsByAppIdDraftWorkflowRunResponses] + export type GetAppsByAppIdDslData = { body?: never path: { @@ -896,6 +2192,76 @@ export type GetAppsByAppIdDslResponses = { export type GetAppsByAppIdDslResponse = GetAppsByAppIdDslResponses[keyof GetAppsByAppIdDslResponses] +export type GetAppsByAppIdEnvData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/env' +} + +export type GetAppsByAppIdEnvErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdEnvError = GetAppsByAppIdEnvErrors[keyof GetAppsByAppIdEnvErrors] + +export type GetAppsByAppIdEnvResponses = { + 200: EnvVariableListResponse +} + +export type GetAppsByAppIdEnvResponse = GetAppsByAppIdEnvResponses[keyof GetAppsByAppIdEnvResponses] + +export type DeleteAppsByAppIdEnvByEnvIdData = { + body?: never + path: { + app_id: string + env_id: string + } + query?: never + url: '/apps/{app_id}/env/{env_id}' +} + +export type DeleteAppsByAppIdEnvByEnvIdErrors = { + default: ErrorBody +} + +export type DeleteAppsByAppIdEnvByEnvIdError = + DeleteAppsByAppIdEnvByEnvIdErrors[keyof DeleteAppsByAppIdEnvByEnvIdErrors] + +export type DeleteAppsByAppIdEnvByEnvIdResponses = { + 200: SimpleResultResponse +} + +export type DeleteAppsByAppIdEnvByEnvIdResponse = + DeleteAppsByAppIdEnvByEnvIdResponses[keyof DeleteAppsByAppIdEnvByEnvIdResponses] + +export type PutAppsByAppIdEnvByEnvIdData = { + body: EnvVariableSetPayload + path: { + app_id: string + env_id: string + } + query?: never + url: '/apps/{app_id}/env/{env_id}' +} + +export type PutAppsByAppIdEnvByEnvIdErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PutAppsByAppIdEnvByEnvIdError = + PutAppsByAppIdEnvByEnvIdErrors[keyof PutAppsByAppIdEnvByEnvIdErrors] + +export type PutAppsByAppIdEnvByEnvIdResponses = { + 200: SimpleResultResponse +} + +export type PutAppsByAppIdEnvByEnvIdResponse = + PutAppsByAppIdEnvByEnvIdResponses[keyof PutAppsByAppIdEnvByEnvIdResponses] + export type PostAppsByAppIdFilesData = { body: FileUploadPayload path: { @@ -972,6 +2338,199 @@ export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses = { export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse = PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses[keyof PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses] +export type GetAppsByAppIdReleaseCheckData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/release:check' +} + +export type GetAppsByAppIdReleaseCheckErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdReleaseCheckError = + GetAppsByAppIdReleaseCheckErrors[keyof GetAppsByAppIdReleaseCheckErrors] + +export type GetAppsByAppIdReleaseCheckResponses = { + 200: ReleaseCheckResponse +} + +export type GetAppsByAppIdReleaseCheckResponse = + GetAppsByAppIdReleaseCheckResponses[keyof GetAppsByAppIdReleaseCheckResponses] + +export type GetAppsByAppIdRunsData = { + body?: never + path: { + app_id: string + } + query?: { + last_id?: string + limit?: number + status?: 'failed' | 'partial-succeeded' | 'running' | 'stopped' | 'succeeded' + triggered_from?: 'app-run' | 'debugging' + } + url: '/apps/{app_id}/runs' +} + +export type GetAppsByAppIdRunsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetAppsByAppIdRunsError = GetAppsByAppIdRunsErrors[keyof GetAppsByAppIdRunsErrors] + +export type GetAppsByAppIdRunsResponses = { + 200: RunListResponse +} + +export type GetAppsByAppIdRunsResponse = + GetAppsByAppIdRunsResponses[keyof GetAppsByAppIdRunsResponses] + +export type GetAppsByAppIdRunsByRunIdData = { + body?: never + path: { + app_id: string + run_id: string + } + query?: never + url: '/apps/{app_id}/runs/{run_id}' +} + +export type GetAppsByAppIdRunsByRunIdErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdRunsByRunIdError = + GetAppsByAppIdRunsByRunIdErrors[keyof GetAppsByAppIdRunsByRunIdErrors] + +export type GetAppsByAppIdRunsByRunIdResponses = { + 200: WorkflowRunDetailResponse +} + +export type GetAppsByAppIdRunsByRunIdResponse = + GetAppsByAppIdRunsByRunIdResponses[keyof GetAppsByAppIdRunsByRunIdResponses] + +export type GetAppsByAppIdRunsByRunIdNodesData = { + body?: never + path: { + app_id: string + run_id: string + } + query?: never + url: '/apps/{app_id}/runs/{run_id}/nodes' +} + +export type GetAppsByAppIdRunsByRunIdNodesErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdRunsByRunIdNodesError = + GetAppsByAppIdRunsByRunIdNodesErrors[keyof GetAppsByAppIdRunsByRunIdNodesErrors] + +export type GetAppsByAppIdRunsByRunIdNodesResponses = { + 200: WorkflowRunNodeExecutionListResponse +} + +export type GetAppsByAppIdRunsByRunIdNodesResponse = + GetAppsByAppIdRunsByRunIdNodesResponses[keyof GetAppsByAppIdRunsByRunIdNodesResponses] + +export type GetAppsByAppIdServiceApiData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/service-api' +} + +export type GetAppsByAppIdServiceApiErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdServiceApiError = + GetAppsByAppIdServiceApiErrors[keyof GetAppsByAppIdServiceApiErrors] + +export type GetAppsByAppIdServiceApiResponses = { + 200: ServiceApi +} + +export type GetAppsByAppIdServiceApiResponse = + GetAppsByAppIdServiceApiResponses[keyof GetAppsByAppIdServiceApiResponses] + +export type PatchAppsByAppIdServiceApiData = { + body: ServiceApiPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/service-api' +} + +export type PatchAppsByAppIdServiceApiErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdServiceApiError = + PatchAppsByAppIdServiceApiErrors[keyof PatchAppsByAppIdServiceApiErrors] + +export type PatchAppsByAppIdServiceApiResponses = { + 200: ServiceApi +} + +export type PatchAppsByAppIdServiceApiResponse = + PatchAppsByAppIdServiceApiResponses[keyof PatchAppsByAppIdServiceApiResponses] + +export type GetAppsByAppIdServiceApiAgentData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/service-api/agent' +} + +export type GetAppsByAppIdServiceApiAgentErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdServiceApiAgentError = + GetAppsByAppIdServiceApiAgentErrors[keyof GetAppsByAppIdServiceApiAgentErrors] + +export type GetAppsByAppIdServiceApiAgentResponses = { + 200: AgentServiceApi +} + +export type GetAppsByAppIdServiceApiAgentResponse = + GetAppsByAppIdServiceApiAgentResponses[keyof GetAppsByAppIdServiceApiAgentResponses] + +export type PatchAppsByAppIdServiceApiAgentData = { + body: ServiceApiPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/service-api/agent' +} + +export type PatchAppsByAppIdServiceApiAgentErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdServiceApiAgentError = + PatchAppsByAppIdServiceApiAgentErrors[keyof PatchAppsByAppIdServiceApiAgentErrors] + +export type PatchAppsByAppIdServiceApiAgentResponses = { + 200: AgentServiceApi +} + +export type PatchAppsByAppIdServiceApiAgentResponse = + PatchAppsByAppIdServiceApiAgentResponses[keyof PatchAppsByAppIdServiceApiAgentResponses] + export type GetAppsByAppIdTasksByTaskIdEventsData = { body?: never path: { @@ -1024,6 +2583,480 @@ export type PostAppsByAppIdTasksByTaskIdStopResponses = { export type PostAppsByAppIdTasksByTaskIdStopResponse = PostAppsByAppIdTasksByTaskIdStopResponses[keyof PostAppsByAppIdTasksByTaskIdStopResponses] +export type GetAppsByAppIdVersionsData = { + body?: never + path: { + app_id: string + } + query?: { + limit?: number + named_only?: boolean + page?: number + } + url: '/apps/{app_id}/versions' +} + +export type GetAppsByAppIdVersionsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetAppsByAppIdVersionsError = + GetAppsByAppIdVersionsErrors[keyof GetAppsByAppIdVersionsErrors] + +export type GetAppsByAppIdVersionsResponses = { + 200: VersionListResponse +} + +export type GetAppsByAppIdVersionsResponse = + GetAppsByAppIdVersionsResponses[keyof GetAppsByAppIdVersionsResponses] + +export type PostAppsByAppIdVersionsByVersionIdRestoreData = { + body?: never + path: { + app_id: string + version_id: string + } + query?: never + url: '/apps/{app_id}/versions/{version_id}:restore' +} + +export type PostAppsByAppIdVersionsByVersionIdRestoreErrors = { + default: ErrorBody +} + +export type PostAppsByAppIdVersionsByVersionIdRestoreError = + PostAppsByAppIdVersionsByVersionIdRestoreErrors[keyof PostAppsByAppIdVersionsByVersionIdRestoreErrors] + +export type PostAppsByAppIdVersionsByVersionIdRestoreResponses = { + 200: RestoreResponse +} + +export type PostAppsByAppIdVersionsByVersionIdRestoreResponse = + PostAppsByAppIdVersionsByVersionIdRestoreResponses[keyof PostAppsByAppIdVersionsByVersionIdRestoreResponses] + +export type GetAppsByAppIdWebappAccessData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp-access' +} + +export type GetAppsByAppIdWebappAccessErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdWebappAccessError = + GetAppsByAppIdWebappAccessErrors[keyof GetAppsByAppIdWebappAccessErrors] + +export type GetAppsByAppIdWebappAccessResponses = { + 200: WebAppAccess +} + +export type GetAppsByAppIdWebappAccessResponse = + GetAppsByAppIdWebappAccessResponses[keyof GetAppsByAppIdWebappAccessResponses] + +export type PutAppsByAppIdWebappAccessData = { + body: WebAppAccessPayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp-access' +} + +export type PutAppsByAppIdWebappAccessErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PutAppsByAppIdWebappAccessError = + PutAppsByAppIdWebappAccessErrors[keyof PutAppsByAppIdWebappAccessErrors] + +export type PutAppsByAppIdWebappAccessResponses = { + 200: WebAppAccess +} + +export type PutAppsByAppIdWebappAccessResponse = + PutAppsByAppIdWebappAccessResponses[keyof PutAppsByAppIdWebappAccessResponses] + +export type GetAppsByAppIdWebappAccessAgentData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp-access/agent' +} + +export type GetAppsByAppIdWebappAccessAgentErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdWebappAccessAgentError = + GetAppsByAppIdWebappAccessAgentErrors[keyof GetAppsByAppIdWebappAccessAgentErrors] + +export type GetAppsByAppIdWebappAccessAgentResponses = { + 200: WebAppAccess +} + +export type GetAppsByAppIdWebappAccessAgentResponse = + GetAppsByAppIdWebappAccessAgentResponses[keyof GetAppsByAppIdWebappAccessAgentResponses] + +export type PutAppsByAppIdWebappAccessAgentData = { + body: WebAppAccessPayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp-access/agent' +} + +export type PutAppsByAppIdWebappAccessAgentErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PutAppsByAppIdWebappAccessAgentError = + PutAppsByAppIdWebappAccessAgentErrors[keyof PutAppsByAppIdWebappAccessAgentErrors] + +export type PutAppsByAppIdWebappAccessAgentResponses = { + 200: WebAppAccess +} + +export type PutAppsByAppIdWebappAccessAgentResponse = + PutAppsByAppIdWebappAccessAgentResponses[keyof PutAppsByAppIdWebappAccessAgentResponses] + +export type GetAppsByAppIdWebappAdvancedChatData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/advanced-chat' +} + +export type GetAppsByAppIdWebappAdvancedChatErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdWebappAdvancedChatError = + GetAppsByAppIdWebappAdvancedChatErrors[keyof GetAppsByAppIdWebappAdvancedChatErrors] + +export type GetAppsByAppIdWebappAdvancedChatResponses = { + 200: AdvancedChatWebApp +} + +export type GetAppsByAppIdWebappAdvancedChatResponse = + GetAppsByAppIdWebappAdvancedChatResponses[keyof GetAppsByAppIdWebappAdvancedChatResponses] + +export type PatchAppsByAppIdWebappAdvancedChatData = { + body: AdvancedChatWebAppPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/advanced-chat' +} + +export type PatchAppsByAppIdWebappAdvancedChatErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdWebappAdvancedChatError = + PatchAppsByAppIdWebappAdvancedChatErrors[keyof PatchAppsByAppIdWebappAdvancedChatErrors] + +export type PatchAppsByAppIdWebappAdvancedChatResponses = { + 200: AdvancedChatWebApp +} + +export type PatchAppsByAppIdWebappAdvancedChatResponse = + PatchAppsByAppIdWebappAdvancedChatResponses[keyof PatchAppsByAppIdWebappAdvancedChatResponses] + +export type GetAppsByAppIdWebappAgentData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/agent' +} + +export type GetAppsByAppIdWebappAgentErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdWebappAgentError = + GetAppsByAppIdWebappAgentErrors[keyof GetAppsByAppIdWebappAgentErrors] + +export type GetAppsByAppIdWebappAgentResponses = { + 200: AgentWebApp +} + +export type GetAppsByAppIdWebappAgentResponse = + GetAppsByAppIdWebappAgentResponses[keyof GetAppsByAppIdWebappAgentResponses] + +export type PatchAppsByAppIdWebappAgentData = { + body: ChatWebAppPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/agent' +} + +export type PatchAppsByAppIdWebappAgentErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdWebappAgentError = + PatchAppsByAppIdWebappAgentErrors[keyof PatchAppsByAppIdWebappAgentErrors] + +export type PatchAppsByAppIdWebappAgentResponses = { + 200: AgentWebApp +} + +export type PatchAppsByAppIdWebappAgentResponse = + PatchAppsByAppIdWebappAgentResponses[keyof PatchAppsByAppIdWebappAgentResponses] + +export type GetAppsByAppIdWebappAgentChatData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/agent-chat' +} + +export type GetAppsByAppIdWebappAgentChatErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdWebappAgentChatError = + GetAppsByAppIdWebappAgentChatErrors[keyof GetAppsByAppIdWebappAgentChatErrors] + +export type GetAppsByAppIdWebappAgentChatResponses = { + 200: ChatWebApp +} + +export type GetAppsByAppIdWebappAgentChatResponse = + GetAppsByAppIdWebappAgentChatResponses[keyof GetAppsByAppIdWebappAgentChatResponses] + +export type PatchAppsByAppIdWebappAgentChatData = { + body: ChatWebAppPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/agent-chat' +} + +export type PatchAppsByAppIdWebappAgentChatErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdWebappAgentChatError = + PatchAppsByAppIdWebappAgentChatErrors[keyof PatchAppsByAppIdWebappAgentChatErrors] + +export type PatchAppsByAppIdWebappAgentChatResponses = { + 200: ChatWebApp +} + +export type PatchAppsByAppIdWebappAgentChatResponse = + PatchAppsByAppIdWebappAgentChatResponses[keyof PatchAppsByAppIdWebappAgentChatResponses] + +export type PostAppsByAppIdWebappAgentResetData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/agent:reset' +} + +export type PostAppsByAppIdWebappAgentResetErrors = { + default: ErrorBody +} + +export type PostAppsByAppIdWebappAgentResetError = + PostAppsByAppIdWebappAgentResetErrors[keyof PostAppsByAppIdWebappAgentResetErrors] + +export type PostAppsByAppIdWebappAgentResetResponses = { + 200: WebAppToken +} + +export type PostAppsByAppIdWebappAgentResetResponse = + PostAppsByAppIdWebappAgentResetResponses[keyof PostAppsByAppIdWebappAgentResetResponses] + +export type GetAppsByAppIdWebappChatData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/chat' +} + +export type GetAppsByAppIdWebappChatErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdWebappChatError = + GetAppsByAppIdWebappChatErrors[keyof GetAppsByAppIdWebappChatErrors] + +export type GetAppsByAppIdWebappChatResponses = { + 200: ChatWebApp +} + +export type GetAppsByAppIdWebappChatResponse = + GetAppsByAppIdWebappChatResponses[keyof GetAppsByAppIdWebappChatResponses] + +export type PatchAppsByAppIdWebappChatData = { + body: ChatWebAppPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/chat' +} + +export type PatchAppsByAppIdWebappChatErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdWebappChatError = + PatchAppsByAppIdWebappChatErrors[keyof PatchAppsByAppIdWebappChatErrors] + +export type PatchAppsByAppIdWebappChatResponses = { + 200: ChatWebApp +} + +export type PatchAppsByAppIdWebappChatResponse = + PatchAppsByAppIdWebappChatResponses[keyof PatchAppsByAppIdWebappChatResponses] + +export type GetAppsByAppIdWebappCompletionData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/completion' +} + +export type GetAppsByAppIdWebappCompletionErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdWebappCompletionError = + GetAppsByAppIdWebappCompletionErrors[keyof GetAppsByAppIdWebappCompletionErrors] + +export type GetAppsByAppIdWebappCompletionResponses = { + 200: WebApp +} + +export type GetAppsByAppIdWebappCompletionResponse = + GetAppsByAppIdWebappCompletionResponses[keyof GetAppsByAppIdWebappCompletionResponses] + +export type PatchAppsByAppIdWebappCompletionData = { + body: WebAppPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/completion' +} + +export type PatchAppsByAppIdWebappCompletionErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdWebappCompletionError = + PatchAppsByAppIdWebappCompletionErrors[keyof PatchAppsByAppIdWebappCompletionErrors] + +export type PatchAppsByAppIdWebappCompletionResponses = { + 200: WebApp +} + +export type PatchAppsByAppIdWebappCompletionResponse = + PatchAppsByAppIdWebappCompletionResponses[keyof PatchAppsByAppIdWebappCompletionResponses] + +export type GetAppsByAppIdWebappWorkflowData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/workflow' +} + +export type GetAppsByAppIdWebappWorkflowErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdWebappWorkflowError = + GetAppsByAppIdWebappWorkflowErrors[keyof GetAppsByAppIdWebappWorkflowErrors] + +export type GetAppsByAppIdWebappWorkflowResponses = { + 200: WorkflowWebApp +} + +export type GetAppsByAppIdWebappWorkflowResponse = + GetAppsByAppIdWebappWorkflowResponses[keyof GetAppsByAppIdWebappWorkflowResponses] + +export type PatchAppsByAppIdWebappWorkflowData = { + body: WorkflowWebAppPatch + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp/workflow' +} + +export type PatchAppsByAppIdWebappWorkflowErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchAppsByAppIdWebappWorkflowError = + PatchAppsByAppIdWebappWorkflowErrors[keyof PatchAppsByAppIdWebappWorkflowErrors] + +export type PatchAppsByAppIdWebappWorkflowResponses = { + 200: WorkflowWebApp +} + +export type PatchAppsByAppIdWebappWorkflowResponse = + PatchAppsByAppIdWebappWorkflowResponses[keyof PatchAppsByAppIdWebappWorkflowResponses] + +export type PostAppsByAppIdWebappResetData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/webapp:reset' +} + +export type PostAppsByAppIdWebappResetErrors = { + default: ErrorBody +} + +export type PostAppsByAppIdWebappResetError = + PostAppsByAppIdWebappResetErrors[keyof PostAppsByAppIdWebappResetErrors] + +export type PostAppsByAppIdWebappResetResponses = { + 200: WebAppToken +} + +export type PostAppsByAppIdWebappResetResponse = + PostAppsByAppIdWebappResetResponses[keyof PostAppsByAppIdWebappResetResponses] + export type PostAppsByAppIdWorkflowRunData = { body: WorkflowRunPayload path: { @@ -1048,6 +3081,72 @@ export type PostAppsByAppIdWorkflowRunResponses = { export type PostAppsByAppIdWorkflowRunResponse = PostAppsByAppIdWorkflowRunResponses[keyof PostAppsByAppIdWorkflowRunResponses] +export type PostAppsByAppIdPublishData = { + body: PublishPayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}:publish' +} + +export type PostAppsByAppIdPublishErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostAppsByAppIdPublishError = + PostAppsByAppIdPublishErrors[keyof PostAppsByAppIdPublishErrors] + +export type PostAppsByAppIdPublishResponses = { + 200: PublishResponse +} + +export type PostAppsByAppIdPublishResponse = + PostAppsByAppIdPublishResponses[keyof PostAppsByAppIdPublishResponses] + +export type GetNodeTypesData = { + body?: never + path?: never + query?: never + url: '/node-types' +} + +export type GetNodeTypesErrors = { + default: ErrorBody +} + +export type GetNodeTypesError = GetNodeTypesErrors[keyof GetNodeTypesErrors] + +export type GetNodeTypesResponses = { + 200: NodeTypeListResponse +} + +export type GetNodeTypesResponse = GetNodeTypesResponses[keyof GetNodeTypesResponses] + +export type GetNodeTypesByNodeTypeData = { + body?: never + path: { + node_type: string + } + query?: never + url: '/node-types/{node_type}' +} + +export type GetNodeTypesByNodeTypeErrors = { + default: ErrorBody +} + +export type GetNodeTypesByNodeTypeError = + GetNodeTypesByNodeTypeErrors[keyof GetNodeTypesByNodeTypeErrors] + +export type GetNodeTypesByNodeTypeResponses = { + 200: NodeTypeDetailResponse +} + +export type GetNodeTypesByNodeTypeResponse = + GetNodeTypesByNodeTypeResponses[keyof GetNodeTypesByNodeTypeResponses] + export type PostOauthDeviceApproveData = { body: DeviceMutateRequest path?: never @@ -1258,6 +3357,30 @@ export type GetWorkspacesByWorkspaceIdResponses = { export type GetWorkspacesByWorkspaceIdResponse = GetWorkspacesByWorkspaceIdResponses[keyof GetWorkspacesByWorkspaceIdResponses] +export type PostWorkspacesByWorkspaceIdAppsAdvancedChatData = { + body: CreateAppPayload + path: { + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/apps/advanced-chat' +} + +export type PostWorkspacesByWorkspaceIdAppsAdvancedChatErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostWorkspacesByWorkspaceIdAppsAdvancedChatError = + PostWorkspacesByWorkspaceIdAppsAdvancedChatErrors[keyof PostWorkspacesByWorkspaceIdAppsAdvancedChatErrors] + +export type PostWorkspacesByWorkspaceIdAppsAdvancedChatResponses = { + 201: CreatedAppResponse +} + +export type PostWorkspacesByWorkspaceIdAppsAdvancedChatResponse = + PostWorkspacesByWorkspaceIdAppsAdvancedChatResponses[keyof PostWorkspacesByWorkspaceIdAppsAdvancedChatResponses] + export type PostWorkspacesByWorkspaceIdAppsImportsData = { body: AppDslImportPayload path: { @@ -1309,6 +3432,111 @@ export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses = { export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse = PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses] +export type PostWorkspacesByWorkspaceIdAppsImportsCheckData = { + body: DslCheckPayload + path: { + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/apps/imports:check' +} + +export type PostWorkspacesByWorkspaceIdAppsImportsCheckErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostWorkspacesByWorkspaceIdAppsImportsCheckError = + PostWorkspacesByWorkspaceIdAppsImportsCheckErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsCheckErrors] + +export type PostWorkspacesByWorkspaceIdAppsImportsCheckResponses = { + 200: DslCheckResponse +} + +export type PostWorkspacesByWorkspaceIdAppsImportsCheckResponse = + PostWorkspacesByWorkspaceIdAppsImportsCheckResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsCheckResponses] + +export type PostWorkspacesByWorkspaceIdAppsWorkflowData = { + body: CreateAppPayload + path: { + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/apps/workflow' +} + +export type PostWorkspacesByWorkspaceIdAppsWorkflowErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostWorkspacesByWorkspaceIdAppsWorkflowError = + PostWorkspacesByWorkspaceIdAppsWorkflowErrors[keyof PostWorkspacesByWorkspaceIdAppsWorkflowErrors] + +export type PostWorkspacesByWorkspaceIdAppsWorkflowResponses = { + 201: CreatedAppResponse +} + +export type PostWorkspacesByWorkspaceIdAppsWorkflowResponse = + PostWorkspacesByWorkspaceIdAppsWorkflowResponses[keyof PostWorkspacesByWorkspaceIdAppsWorkflowResponses] + +export type GetWorkspacesByWorkspaceIdKnowledgeBasesData = { + body?: never + path: { + workspace_id: string + } + query?: { + limit?: number + page?: number + query?: string + } + url: '/workspaces/{workspace_id}/knowledge-bases' +} + +export type GetWorkspacesByWorkspaceIdKnowledgeBasesErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetWorkspacesByWorkspaceIdKnowledgeBasesError = + GetWorkspacesByWorkspaceIdKnowledgeBasesErrors[keyof GetWorkspacesByWorkspaceIdKnowledgeBasesErrors] + +export type GetWorkspacesByWorkspaceIdKnowledgeBasesResponses = { + 200: KnowledgeBaseListResponse +} + +export type GetWorkspacesByWorkspaceIdKnowledgeBasesResponse = + GetWorkspacesByWorkspaceIdKnowledgeBasesResponses[keyof GetWorkspacesByWorkspaceIdKnowledgeBasesResponses] + +export type GetWorkspacesByWorkspaceIdMarketplacePluginsData = { + body?: never + path: { + workspace_id: string + } + query?: { + category?: 'agent-strategy' | 'datasource' | 'extension' | 'model' | 'tool' | 'trigger' + limit?: number + page?: number + query?: string + } + url: '/workspaces/{workspace_id}/marketplace/plugins' +} + +export type GetWorkspacesByWorkspaceIdMarketplacePluginsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetWorkspacesByWorkspaceIdMarketplacePluginsError = + GetWorkspacesByWorkspaceIdMarketplacePluginsErrors[keyof GetWorkspacesByWorkspaceIdMarketplacePluginsErrors] + +export type GetWorkspacesByWorkspaceIdMarketplacePluginsResponses = { + 200: MarketplacePluginListResponse +} + +export type GetWorkspacesByWorkspaceIdMarketplacePluginsResponse = + GetWorkspacesByWorkspaceIdMarketplacePluginsResponses[keyof GetWorkspacesByWorkspaceIdMarketplacePluginsResponses] + export type GetWorkspacesByWorkspaceIdMembersData = { body?: never path: { @@ -1409,25 +3637,366 @@ export type PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses = { export type PatchWorkspacesByWorkspaceIdMembersByMemberIdResponse = PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses[keyof PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses] -export type PostWorkspacesByWorkspaceIdSwitchData = { +export type GetWorkspacesByWorkspaceIdModelProvidersByProviderData = { + body?: never + path: { + provider: string + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/model-providers/{provider}' +} + +export type GetWorkspacesByWorkspaceIdModelProvidersByProviderErrors = { + default: ErrorBody +} + +export type GetWorkspacesByWorkspaceIdModelProvidersByProviderError = + GetWorkspacesByWorkspaceIdModelProvidersByProviderErrors[keyof GetWorkspacesByWorkspaceIdModelProvidersByProviderErrors] + +export type GetWorkspacesByWorkspaceIdModelProvidersByProviderResponses = { + 200: ModelProviderDetailResponse +} + +export type GetWorkspacesByWorkspaceIdModelProvidersByProviderResponse = + GetWorkspacesByWorkspaceIdModelProvidersByProviderResponses[keyof GetWorkspacesByWorkspaceIdModelProvidersByProviderResponses] + +export type PostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsData = { + body: ProviderCredentialCreatePayload + path: { + provider: string + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/model-providers/{provider}/credentials' +} + +export type PostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsError = + PostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsErrors[keyof PostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsErrors] + +export type PostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsResponses = { + 201: CredentialWriteResponse +} + +export type PostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsResponse = + PostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsResponses[keyof PostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsResponses] + +export type PatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdData = { + body: ProviderCredentialUpdatePayload + path: { + credential_id: string + provider: string + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/model-providers/{provider}/credentials/{credential_id}' +} + +export type PatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdError = + PatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdErrors[keyof PatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdErrors] + +export type PatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdResponses = + { + 200: CredentialWriteResponse + } + +export type PatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdResponse = + PatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdResponses[keyof PatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdResponses] + +export type PostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsData = { + body: ModelCredentialCreatePayload + path: { + provider: string + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/model-providers/{provider}/models/credentials' +} + +export type PostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsError = + PostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsErrors[keyof PostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsErrors] + +export type PostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsResponses = { + 201: CredentialWriteResponse +} + +export type PostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsResponse = + PostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsResponses[keyof PostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsResponses] + +export type PatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdData = + { + body: ModelCredentialUpdatePayload + path: { + credential_id: string + provider: string + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/model-providers/{provider}/models/credentials/{credential_id}' + } + +export type PatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdErrors = + { + 422: ErrorBody + default: ErrorBody + } + +export type PatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdError = + PatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdErrors[keyof PatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdErrors] + +export type PatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdResponses = + { + 200: CredentialWriteResponse + } + +export type PatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdResponse = + PatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdResponses[keyof PatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdResponses] + +export type GetWorkspacesByWorkspaceIdModelsData = { body?: never path: { workspace_id: string } - query?: never - url: '/workspaces/{workspace_id}:switch' + query?: { + limit?: number + model_type?: 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts' + page?: number + provider?: string + query?: string + } + url: '/workspaces/{workspace_id}/models' } -export type PostWorkspacesByWorkspaceIdSwitchErrors = { +export type GetWorkspacesByWorkspaceIdModelsErrors = { + 422: ErrorBody default: ErrorBody } -export type PostWorkspacesByWorkspaceIdSwitchError = - PostWorkspacesByWorkspaceIdSwitchErrors[keyof PostWorkspacesByWorkspaceIdSwitchErrors] +export type GetWorkspacesByWorkspaceIdModelsError = + GetWorkspacesByWorkspaceIdModelsErrors[keyof GetWorkspacesByWorkspaceIdModelsErrors] -export type PostWorkspacesByWorkspaceIdSwitchResponses = { - 200: WorkspaceDetailResponse +export type GetWorkspacesByWorkspaceIdModelsResponses = { + 200: ModelListResponse } -export type PostWorkspacesByWorkspaceIdSwitchResponse = - PostWorkspacesByWorkspaceIdSwitchResponses[keyof PostWorkspacesByWorkspaceIdSwitchResponses] +export type GetWorkspacesByWorkspaceIdModelsResponse = + GetWorkspacesByWorkspaceIdModelsResponses[keyof GetWorkspacesByWorkspaceIdModelsResponses] + +export type GetWorkspacesByWorkspaceIdPluginTasksByTaskIdData = { + body?: never + path: { + task_id: string + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/plugin-tasks/{task_id}' +} + +export type GetWorkspacesByWorkspaceIdPluginTasksByTaskIdErrors = { + default: ErrorBody +} + +export type GetWorkspacesByWorkspaceIdPluginTasksByTaskIdError = + GetWorkspacesByWorkspaceIdPluginTasksByTaskIdErrors[keyof GetWorkspacesByWorkspaceIdPluginTasksByTaskIdErrors] + +export type GetWorkspacesByWorkspaceIdPluginTasksByTaskIdResponses = { + 200: PluginTaskResponse +} + +export type GetWorkspacesByWorkspaceIdPluginTasksByTaskIdResponse = + GetWorkspacesByWorkspaceIdPluginTasksByTaskIdResponses[keyof GetWorkspacesByWorkspaceIdPluginTasksByTaskIdResponses] + +export type GetWorkspacesByWorkspaceIdPluginsData = { + body?: never + path: { + workspace_id: string + } + query?: { + category?: 'agent-strategy' | 'datasource' | 'extension' | 'model' | 'tool' | 'trigger' + } + url: '/workspaces/{workspace_id}/plugins' +} + +export type GetWorkspacesByWorkspaceIdPluginsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetWorkspacesByWorkspaceIdPluginsError = + GetWorkspacesByWorkspaceIdPluginsErrors[keyof GetWorkspacesByWorkspaceIdPluginsErrors] + +export type GetWorkspacesByWorkspaceIdPluginsResponses = { + 200: PluginListResponse +} + +export type GetWorkspacesByWorkspaceIdPluginsResponse = + GetWorkspacesByWorkspaceIdPluginsResponses[keyof GetWorkspacesByWorkspaceIdPluginsResponses] + +export type DeleteWorkspacesByWorkspaceIdPluginsByPluginIdData = { + body?: never + path: { + plugin_id: string + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/plugins/{plugin_id}' +} + +export type DeleteWorkspacesByWorkspaceIdPluginsByPluginIdErrors = { + default: ErrorBody +} + +export type DeleteWorkspacesByWorkspaceIdPluginsByPluginIdError = + DeleteWorkspacesByWorkspaceIdPluginsByPluginIdErrors[keyof DeleteWorkspacesByWorkspaceIdPluginsByPluginIdErrors] + +export type DeleteWorkspacesByWorkspaceIdPluginsByPluginIdResponses = { + 200: PluginDeleteResponse +} + +export type DeleteWorkspacesByWorkspaceIdPluginsByPluginIdResponse = + DeleteWorkspacesByWorkspaceIdPluginsByPluginIdResponses[keyof DeleteWorkspacesByWorkspaceIdPluginsByPluginIdResponses] + +export type PostWorkspacesByWorkspaceIdPluginsInstallData = { + body: PluginInstallPayload + path: { + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/plugins:install' +} + +export type PostWorkspacesByWorkspaceIdPluginsInstallErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostWorkspacesByWorkspaceIdPluginsInstallError = + PostWorkspacesByWorkspaceIdPluginsInstallErrors[keyof PostWorkspacesByWorkspaceIdPluginsInstallErrors] + +export type PostWorkspacesByWorkspaceIdPluginsInstallResponses = { + 200: PluginTaskStartResponse +} + +export type PostWorkspacesByWorkspaceIdPluginsInstallResponse = + PostWorkspacesByWorkspaceIdPluginsInstallResponses[keyof PostWorkspacesByWorkspaceIdPluginsInstallResponses] + +export type GetWorkspacesByWorkspaceIdToolProvidersByProviderData = { + body?: never + path: { + provider: string + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/tool-providers/{provider}' +} + +export type GetWorkspacesByWorkspaceIdToolProvidersByProviderErrors = { + default: ErrorBody +} + +export type GetWorkspacesByWorkspaceIdToolProvidersByProviderError = + GetWorkspacesByWorkspaceIdToolProvidersByProviderErrors[keyof GetWorkspacesByWorkspaceIdToolProvidersByProviderErrors] + +export type GetWorkspacesByWorkspaceIdToolProvidersByProviderResponses = { + 200: ToolProviderDetailResponse +} + +export type GetWorkspacesByWorkspaceIdToolProvidersByProviderResponse = + GetWorkspacesByWorkspaceIdToolProvidersByProviderResponses[keyof GetWorkspacesByWorkspaceIdToolProvidersByProviderResponses] + +export type PostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsData = { + body: ToolCredentialCreatePayload + path: { + provider: string + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/tool-providers/{provider}/credentials' +} + +export type PostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsError = + PostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsErrors[keyof PostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsErrors] + +export type PostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsResponses = { + 201: CredentialWriteResponse +} + +export type PostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsResponse = + PostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsResponses[keyof PostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsResponses] + +export type PatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdData = { + body: ToolCredentialUpdatePayload + path: { + credential_id: string + provider: string + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}/tool-providers/{provider}/credentials/{credential_id}' +} + +export type PatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdError = + PatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdErrors[keyof PatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdErrors] + +export type PatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdResponses = + { + 200: CredentialWriteResponse + } + +export type PatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdResponse = + PatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdResponses[keyof PatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdResponses] + +export type GetWorkspacesByWorkspaceIdToolsData = { + body?: never + path: { + workspace_id: string + } + query?: { + limit?: number + page?: number + provider?: string + provider_type?: 'api' | 'builtin' | 'mcp' | 'workflow' + query?: string + } + url: '/workspaces/{workspace_id}/tools' +} + +export type GetWorkspacesByWorkspaceIdToolsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetWorkspacesByWorkspaceIdToolsError = + GetWorkspacesByWorkspaceIdToolsErrors[keyof GetWorkspacesByWorkspaceIdToolsErrors] + +export type GetWorkspacesByWorkspaceIdToolsResponses = { + 200: ToolListResponse +} + +export type GetWorkspacesByWorkspaceIdToolsResponse = + GetWorkspacesByWorkspaceIdToolsResponses[keyof GetWorkspacesByWorkspaceIdToolsResponses] diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 2e4b22e2c83..b3738f7103b 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -11,6 +11,23 @@ export const zAccountPayload = z.object({ name: z.string(), }) +/** + * AdvancedChatNodeRunPayload + */ +export const zAdvancedChatNodeRunPayload = z.object({ + files: z + .record( + z.string(), + z.union([ + z.custom((value) => value instanceof Blob || value instanceof File), + z.array(z.custom((value) => value instanceof Blob || value instanceof File)), + ]), + ) + .nullish(), + inputs: z.record(z.string(), z.unknown()).optional(), + query: z.string().optional().default(''), +}) + /** * AdvancedChatRunPayload * @@ -31,12 +48,120 @@ export const zAdvancedChatRunPayload = z.object({ ]), ) .nullish(), - inputs: z.record(z.string(), z.unknown()), + inputs: z.record(z.string(), z.unknown()).optional(), query: z.string(), workflow_id: z.string().nullish(), workspace_id: z.string().nullish(), }) +/** + * AdvancedChatWebApp + */ +export const zAdvancedChatWebApp = z.object({ + access_token: z.string().nullish(), + app_base_url: z.string(), + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean().optional().default(false), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string().nullish(), + description: z.string().nullish(), + enabled: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + input_placeholder: z.string().nullish(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean().optional().default(false), + title: z.string().nullish(), + url: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().optional().default(false), +}) + +/** + * AdvancedChatWebAppPatch + */ +export const zAdvancedChatWebAppPatch = z.object({ + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean().nullish(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string().nullish(), + description: z.string().nullish(), + enabled: z.boolean().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.enum(['emoji', 'image', 'link']).nullish(), + input_placeholder: z.string().nullish(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean().nullish(), + title: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), +}) + +/** + * AgentAppInfo + */ +export const zAgentAppInfo = z.object({ + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + max_active_requests: z.int().nullish(), + name: z.string(), + role: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().optional().default(false), +}) + +/** + * AgentAppInfoPatch + */ +export const zAgentAppInfoPatch = z.object({ + description: z.string().max(400).nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.enum(['emoji', 'image', 'link']).nullish(), + max_active_requests: z.int().gte(0).nullish(), + name: z.string().min(1).nullish(), + role: z.string().max(255).nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), +}) + +/** + * AgentServiceApi + */ +export const zAgentServiceApi = z.object({ + access_ready: z.boolean(), + api_rph: z.int().optional().default(0), + api_rpm: z.int().optional().default(0), + base_url: z.string(), + enabled: z.boolean(), +}) + +/** + * AgentWebApp + */ +export const zAgentWebApp = z.object({ + access_ready: z.boolean(), + access_token: z.string().nullish(), + app_base_url: z.string(), + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean().optional().default(false), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string().nullish(), + description: z.string().nullish(), + enabled: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + input_placeholder: z.string().nullish(), + privacy_policy: z.string().nullish(), + title: z.string().nullish(), + url: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().optional().default(false), +}) + /** * AppDescribeInfo */ @@ -61,15 +186,6 @@ export const zAppDescribeQuery = z.object({ fields: z.string().optional(), }) -/** - * AppDescribeResponse - */ -export const zAppDescribeResponse = z.object({ - info: zAppDescribeInfo.nullish(), - input_schema: z.record(z.string(), z.unknown()).nullish(), - parameters: z.record(z.string(), z.unknown()).nullish(), -}) - /** * AppDslExportQuery * @@ -87,6 +203,7 @@ export const zAppDslExportQuery = z.object({ */ export const zAppDslExportResponse = z.object({ data: z.string(), + draft_hash: z.string().nullish(), }) /** @@ -97,6 +214,7 @@ export const zAppDslExportResponse = z.object({ export const zAppDslImportPayload = z.object({ app_id: z.string().nullish(), description: z.string().nullish(), + draft_hash: z.string().nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), @@ -116,6 +234,18 @@ export const zAppInfo = z.object({ name: z.string(), }) +/** + * AppInfoPatch + */ +export const zAppInfoPatch = z.object({ + description: z.string().max(400).nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.enum(['emoji', 'image', 'link']).nullish(), + max_active_requests: z.int().gte(0).nullish(), + name: z.string().min(1).nullish(), +}) + /** * AppMode */ @@ -143,6 +273,44 @@ export const zAppListRow = z.object({ workspace_name: z.string().nullish(), }) +/** + * AppSettingsInfo + */ +export const zAppSettingsInfo = z.object({ + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + max_active_requests: z.int().nullish(), + name: z.string(), +}) + +/** + * ChatAppInfo + */ +export const zChatAppInfo = z.object({ + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + max_active_requests: z.int().nullish(), + name: z.string(), + use_icon_as_answer_icon: z.boolean().optional().default(false), +}) + +/** + * ChatAppInfoPatch + */ +export const zChatAppInfoPatch = z.object({ + description: z.string().max(400).nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.enum(['emoji', 'image', 'link']).nullish(), + max_active_requests: z.int().gte(0).nullish(), + name: z.string().min(1).nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), +}) + /** * ChatRunPayload */ @@ -161,11 +329,54 @@ export const zChatRunPayload = z.object({ ]), ) .nullish(), - inputs: z.record(z.string(), z.unknown()), + inputs: z.record(z.string(), z.unknown()).optional(), query: z.string(), workspace_id: z.string().nullish(), }) +/** + * ChatWebApp + */ +export const zChatWebApp = z.object({ + access_token: z.string().nullish(), + app_base_url: z.string(), + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean().optional().default(false), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string().nullish(), + description: z.string().nullish(), + enabled: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + input_placeholder: z.string().nullish(), + privacy_policy: z.string().nullish(), + title: z.string().nullish(), + url: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().optional().default(false), +}) + +/** + * ChatWebAppPatch + */ +export const zChatWebAppPatch = z.object({ + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean().nullish(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string().nullish(), + description: z.string().nullish(), + enabled: z.boolean().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.enum(['emoji', 'image', 'link']).nullish(), + input_placeholder: z.string().nullish(), + privacy_policy: z.string().nullish(), + title: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), +}) + /** * CompletionRunPayload */ @@ -182,11 +393,61 @@ export const zCompletionRunPayload = z.object({ ]), ) .nullish(), - inputs: z.record(z.string(), z.unknown()), + inputs: z.record(z.string(), z.unknown()).optional(), query: z.string().optional().default(''), workspace_id: z.string().nullish(), }) +/** + * CreateAppPayload + */ +export const zCreateAppPayload = z.object({ + description: z.string().max(400).nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.enum(['emoji', 'image', 'link']).nullish(), + name: z.string().min(1), +}) + +/** + * CreatedAppResponse + */ +export const zCreatedAppResponse = z.object({ + app_id: z.string(), + mode: z.string(), + name: z.string(), +}) + +/** + * CredentialFormField + */ +export const zCredentialFormField = z.object({ + label: z.string().nullable(), + name: z.string(), + options: z.array(z.string()).nullable(), + placeholder: z.string().nullable(), + required: z.boolean(), + show_on: z.array(z.record(z.string(), z.string())), + type: z.string(), +}) + +/** + * CredentialRef + */ +export const zCredentialRef = z.object({ + id: z.string(), + name: z.string().nullable(), +}) + +/** + * CustomModelRow + */ +export const zCustomModelRow = z.object({ + active_credential: zCredentialRef.nullable(), + model: z.string(), + model_type: z.string(), +}) + /** * DeploymentEdition * @@ -272,6 +533,34 @@ export const zDevicePollRequest = z.object({ device_code: z.string(), }) +/** + * DraftWorkflowRunPayload + */ +export const zDraftWorkflowRunPayload = z.object({ + attachments: z + .array(z.custom((value) => value instanceof Blob || value instanceof File)) + .nullish(), + files: z + .record( + z.string(), + z.union([ + z.custom((value) => value instanceof Blob || value instanceof File), + z.array(z.custom((value) => value instanceof Blob || value instanceof File)), + ]), + ) + .nullish(), + inputs: z.record(z.string(), z.unknown()).optional(), + workspace_id: z.string().nullish(), +}) + +/** + * DslCheckPayload + */ +export const zDslCheckPayload = z.object({ + app_id: z.string().nullish(), + yaml_content: z.string(), +}) + /** * DslImportWarning * @@ -284,6 +573,56 @@ export const zDslImportWarning = z.object({ path: z.string(), }) +/** + * DslIssueRow + */ +export const zDslIssueRow = z.object({ + code: z.string(), + loc: z.array(z.union([z.string(), z.int()])), + message: z.string(), + node_id: z.string().nullable(), + severity: z.string(), +}) + +/** + * EnvVariableRow + */ +export const zEnvVariableRow = z.object({ + description: z.string().optional().default(''), + id: z.string(), + name: z.string(), + value: z.unknown(), + value_type: z.string(), +}) + +/** + * EnvVariableListResponse + */ +export const zEnvVariableListResponse = z.object({ + data: z.array(zEnvVariableRow), +}) + +/** + * EnvVariableValueType + * + * Value types the draft environment-variable ``set`` op accepts. + * + * A curated subset of ``SegmentType``: what the console's environment-variable editor + * allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py). + * Members reference ``SegmentType.*.value`` so the subset relationship is type-checked. + */ +export const zEnvVariableValueType = z.enum(['number', 'secret', 'string']) + +/** + * EnvVariableSetPayload + */ +export const zEnvVariableSetPayload = z.object({ + description: z.string().optional().default(''), + name: z.string(), + value: z.unknown(), + value_type: zEnvVariableValueType, +}) + /** * ErrorDetail */ @@ -296,21 +635,6 @@ export const zErrorDetail = z.object({ type: z.string(), }) -/** - * ErrorBody - * - * Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the - * generated client schema stays an open enum — old CLIs keep parsing when a - * future server adds a code. Formatter tests pin emitted values to the enum. - */ -export const zErrorBody = z.object({ - code: z.string(), - details: z.array(zErrorDetail).nullish(), - hint: z.string().nullish(), - message: z.string(), - status: z.int(), -}) - /** * EventStreamResponse */ @@ -344,15 +668,6 @@ export const zFileUploadPayload = z.object({ file: z.custom((value) => value instanceof Blob || value instanceof File), }) -/** - * FormSubmitResponse - * - * Empty 200 body for POST /apps//human-input-forms/:submit. `extra='forbid'` - * pins `additionalProperties: false` so the generated contract is an exact `{}` rather - * than an under-annotated open object. - */ -export const zFormSubmitResponse = z.record(z.string(), z.never()) - /** * Github */ @@ -375,7 +690,7 @@ export const zHealthResponse = z.object({ /** * Hint * - * A next step the caller can hand straight to `call --input `. + * A next step: the op id to call and its ready input. */ export const zHint = z.object({ form: z.array(z.record(z.string(), z.unknown())).nullish(), @@ -384,6 +699,16 @@ export const zHint = z.object({ summary: z.string(), }) +/** + * AppDescribeResponse + */ +export const zAppDescribeResponse = z.object({ + hints: z.array(zHint).optional(), + info: zAppDescribeInfo.nullish(), + input_schema: z.record(z.string(), z.unknown()).nullish(), + parameters: z.record(z.string(), z.unknown()).nullish(), +}) + /** * AppListResponse */ @@ -396,6 +721,52 @@ export const zAppListResponse = z.object({ total: z.int(), }) +/** + * CredentialWriteResponse + */ +export const zCredentialWriteResponse = z.object({ + active: z.boolean(), + hints: z.array(zHint).optional(), + id: z.string(), + name: z.string().nullable(), +}) + +/** + * DslCheckResponse + */ +export const zDslCheckResponse = z.object({ + hints: z.array(zHint).optional(), + issues: z.array(zDslIssueRow), + valid: z.boolean(), +}) + +/** + * ErrorBody + * + * Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the + * generated client schema stays an open enum — old CLIs keep parsing when a + * future server adds a code. Formatter tests pin emitted values to the enum. + */ +export const zErrorBody = z.object({ + code: z.string(), + details: z.array(zErrorDetail).nullish(), + hint: z.string().nullish(), + hints: z.array(zHint).nullish(), + message: z.string(), + status: z.int(), +}) + +/** + * FormSubmitResponse + * + * 200 body for POST /apps//human-input-forms/:submit: the hint to follow the + * resumed run. `extra='forbid'` pins `additionalProperties: false` so the generated contract + * is exact rather than an under-annotated open object. + */ +export const zFormSubmitResponse = z.object({ + hints: z.array(zHint).optional(), +}) + /** * HumanInputFormDefinitionResponse */ @@ -447,6 +818,25 @@ export const zImport = z.object({ export const zJsonValue = z.unknown() +/** + * KnowledgeBaseListQuery + */ +export const zKnowledgeBaseListQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), + query: z.string().optional().default(''), +}) + +/** + * LlmModelBlock + */ +export const zLlmModelBlock = z.object({ + completion_params: z.record(z.string(), z.unknown()).optional(), + mode: z.string(), + name: z.string(), + provider: z.string(), +}) + /** * Marketplace */ @@ -455,6 +845,34 @@ export const zMarketplace = z.object({ version: z.string().nullish(), }) +/** + * MarketplacePluginRow + */ +export const zMarketplacePluginRow = z.object({ + authorized_category: z.string().nullable(), + brief: z.string().nullable(), + category: z.string(), + identifier: z.string(), + install_count: z.int(), + installed: z.boolean(), + installed_version: z.string().nullable(), + label: z.string().nullable(), + plugin_id: z.string(), + version: z.string(), +}) + +/** + * MarketplacePluginListResponse + */ +export const zMarketplacePluginListResponse = z.object({ + data: z.array(zMarketplacePluginRow), + has_more: z.boolean(), + hints: z.array(zHint).optional(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * MemberActionResponse */ @@ -523,11 +941,212 @@ export const zMemberRoleUpdatePayload = z.object({ role: z.enum(['admin', 'normal']), }) +/** + * ModelProviderDetailResponse + */ +export const zModelProviderDetailResponse = z.object({ + active_credential: zCredentialRef.nullable(), + configured: z.boolean(), + credential_form: z.array(zCredentialFormField), + credentials: z.array(zCredentialRef), + custom_model_form: z.array(zCredentialFormField).nullable(), + custom_models: z.array(zCustomModelRow), + hints: z.array(zHint).optional(), + label: z.string().nullable(), + model_types: z.array(z.string()), + provider: z.string(), +}) + +/** + * ModelProviderRow + */ +export const zModelProviderRow = z.object({ + active_credential: zCredentialRef.nullable(), + configured: z.boolean(), + label: z.string().nullable(), + model_types: z.array(z.string()), + provider: z.string(), +}) + +/** + * ModelRow + */ +export const zModelRow = z.object({ + features: z.array(z.string()), + label: z.string().nullable(), + model: z.string(), + model_type: z.string(), + node_model: zLlmModelBlock.nullish(), + provider: z.string(), + provider_label: z.string().nullable(), + status: z.string(), +}) + +/** + * ModelListResponse + */ +export const zModelListResponse = z.object({ + data: z.array(zModelRow), + has_more: z.boolean(), + hints: z.array(zHint).optional(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * ModelType + * + * Enum class for model type. + */ +export const zModelType = z.enum([ + 'llm', + 'moderation', + 'rerank', + 'speech2text', + 'text-embedding', + 'tts', +]) + +/** + * ModelCredentialCreatePayload + */ +export const zModelCredentialCreatePayload = z.object({ + credentials: z.record(z.string(), z.unknown()), + model: z.string(), + model_type: zModelType, + name: z.string().nullish(), +}) + +/** + * ModelCredentialUpdatePayload + */ +export const zModelCredentialUpdatePayload = z.object({ + credentials: z.record(z.string(), z.unknown()), + model: z.string(), + model_type: zModelType, + name: z.string().nullish(), +}) + +/** + * ModelListQuery + */ +export const zModelListQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + model_type: zModelType.nullish(), + page: z.int().gte(1).optional().default(1), + provider: z.string().nullish(), + query: z.string().optional().default(''), +}) + +/** + * ModelRef + */ +export const zModelRef = z.object({ + model: z.string(), + model_type: zModelType, +}) + +/** + * MultipleRetrievalFragment + */ +export const zMultipleRetrievalFragment = z.object({ + reranking_enable: z.boolean().optional().default(false), + top_k: z.int().optional().default(4), +}) + +/** + * KnowledgeRetrievalFragment + */ +export const zKnowledgeRetrievalFragment = z.object({ + dataset_ids: z.array(z.string()), + multiple_retrieval_config: zMultipleRetrievalFragment.optional(), + retrieval_mode: z.literal('multiple').optional().default('multiple'), +}) + +/** + * KnowledgeBaseRow + */ +export const zKnowledgeBaseRow = z.object({ + description: z.string().nullable(), + document_count: z.int(), + id: z.string(), + indexing_technique: z.string().nullable(), + name: z.string(), + node_data: zKnowledgeRetrievalFragment, + provider: z.string(), + usable: z.boolean(), +}) + +/** + * KnowledgeBaseListResponse + */ +export const zKnowledgeBaseListResponse = z.object({ + data: z.array(zKnowledgeBaseRow), + has_more: z.boolean(), + hints: z.array(zHint).optional(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * NodeChange + */ +export const zNodeChange = z.object({ + fields: z.array(z.string()), + id: z.string(), + title: z.string(), + type: z.string(), +}) + +/** + * NodeRunPayload + */ +export const zNodeRunPayload = z.object({ + files: z + .record( + z.string(), + z.union([ + z.custom((value) => value instanceof Blob || value instanceof File), + z.array(z.custom((value) => value instanceof Blob || value instanceof File)), + ]), + ) + .nullish(), + inputs: z.record(z.string(), z.unknown()).optional(), +}) + +/** + * NodeTypeDetailResponse + */ +export const zNodeTypeDetailResponse = z.object({ + default_config: z.record(z.string(), z.unknown()), + schema: z.record(z.string(), z.unknown()), + type: z.string(), + version: z.string(), +}) + +/** + * NodeTypeRow + */ +export const zNodeTypeRow = z.object({ + type: z.string(), + version: z.string(), +}) + +/** + * NodeTypeListResponse + */ +export const zNodeTypeListResponse = z.object({ + data: z.array(zNodeTypeRow), +}) + /** * OpenApiErrorCode */ export const zOpenApiErrorCode = z.enum([ 'agent_not_published', + 'app_mode_mismatch', 'app_unavailable', 'bad_gateway', 'bad_request', @@ -535,6 +1154,13 @@ export const zOpenApiErrorCode = z.enum([ 'completion_request_error', 'conflict', 'conversation_completed', + 'credential_invalid', + 'credential_not_found', + 'credential_oauth_only', + 'draft_changed', + 'draft_not_found', + 'dsl_invalid', + 'env_variable_not_found', 'file_extension_blocked', 'file_too_large', 'filename_not_exists', @@ -542,18 +1168,30 @@ export const zOpenApiErrorCode = z.enum([ 'form_not_found', 'internal_server_error', 'invalid_param', + 'marketplace_disabled', + 'marketplace_unavailable', 'member_license_exceeded', 'member_limit_exceeded', 'method_not_allowed', 'model_currently_not_support', 'no_file_uploaded', + 'node_not_found', + 'node_type_not_found', 'not_acceptable', 'not_found', + 'plugin_install_forbidden', + 'plugin_not_installed', + 'plugin_service_unavailable', + 'provider_not_found', 'provider_not_initialize', 'provider_quota_exceeded', 'rate_limit_error', 'recipient_surface_mismatch', 'request_entity_too_large', + 'run_not_found', + 'secret_mask_not_secret', + 'secret_mask_unknown_id', + 'service_api_disabled', 'too_many_files', 'too_many_requests', 'trigger_workflow_service_mode_unavailable', @@ -561,6 +1199,12 @@ export const zOpenApiErrorCode = z.enum([ 'unknown', 'unsupported_file_type', 'unsupported_media_type', + 'version_not_found', + 'version_not_restorable', + 'webapp_access_mode_console_only', + 'webapp_access_requires_ee', + 'webapp_access_unavailable', + 'webapp_public_access_disabled', ]) /** @@ -602,6 +1246,36 @@ export const zPermittedExternalAppsListResponse = z.object({ total: z.int(), }) +/** + * PluginCategory + */ +export const zPluginCategory = z.enum([ + 'agent-strategy', + 'datasource', + 'extension', + 'model', + 'tool', + 'trigger', +]) + +/** + * MarketplacePluginQuery + */ +export const zMarketplacePluginQuery = z.object({ + category: zPluginCategory.nullish(), + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), + query: z.string().optional().default(''), +}) + +/** + * PluginDeleteResponse + */ +export const zPluginDeleteResponse = z.object({ + deleted: z.boolean(), + plugin_id: z.string(), +}) + /** * PluginDependencyType */ @@ -616,6 +1290,14 @@ export const zPluginDependency = z.object({ value: z.union([zGithub, zMarketplace, zPackage]), }) +/** + * CheckDependenciesResponse + */ +export const zCheckDependenciesResponse = z.object({ + hints: z.array(zHint).optional(), + leaked_dependencies: z.array(zPluginDependency).optional(), +}) + /** * CheckDependenciesResult */ @@ -623,6 +1305,147 @@ export const zCheckDependenciesResult = z.object({ leaked_dependencies: z.array(zPluginDependency).optional(), }) +/** + * PluginInstallPayload + */ +export const zPluginInstallPayload = z.object({ + identifiers: z.array(z.string()).min(1), +}) + +/** + * PluginInstallTaskStatus + */ +export const zPluginInstallTaskStatus = z.enum(['failed', 'pending', 'running', 'success']) + +/** + * PluginListQuery + */ +export const zPluginListQuery = z.object({ + category: zPluginCategory.nullish(), +}) + +/** + * PluginProvides + */ +export const zPluginProvides = z.object({ + model_provider: z.string().nullable(), + tool_provider: z.string().nullable(), +}) + +/** + * PluginRow + */ +export const zPluginRow = z.object({ + category: z.string(), + identifier: z.string(), + label: z.string().nullable(), + latest_version: z.string().nullable(), + plugin_id: z.string(), + provides: zPluginProvides, + source: z.string(), + version: z.string(), +}) + +/** + * PluginListResponse + */ +export const zPluginListResponse = z.object({ + data: z.array(zPluginRow), + hints: z.array(zHint).optional(), +}) + +/** + * PluginTaskItem + */ +export const zPluginTaskItem = z.object({ + identifier: z.string(), + message: z.string(), + plugin_id: z.string(), + status: zPluginInstallTaskStatus, +}) + +/** + * PluginTaskResponse + */ +export const zPluginTaskResponse = z.object({ + hints: z.array(zHint).optional(), + plugins: z.array(zPluginTaskItem), + status: zPluginInstallTaskStatus, + task_id: z.string(), +}) + +/** + * PluginTaskStartResponse + */ +export const zPluginTaskStartResponse = z.object({ + all_installed: z.boolean(), + hints: z.array(zHint).optional(), + task_id: z.string(), +}) + +/** + * ProviderCredentialCreatePayload + */ +export const zProviderCredentialCreatePayload = z.object({ + credentials: z.record(z.string(), z.unknown()), + name: z.string().nullish(), +}) + +/** + * ProviderCredentialUpdatePayload + */ +export const zProviderCredentialUpdatePayload = z.object({ + credentials: z.record(z.string(), z.unknown()), + name: z.string().nullish(), +}) + +/** + * PublishPayload + */ +export const zPublishPayload = z.object({ + marked_comment: z.string().max(100).optional().default(''), + marked_name: z.string().max(20).optional().default(''), +}) + +/** + * PublishResponse + */ +export const zPublishResponse = z.object({ + created_at: z.int(), + version_id: z.string(), + warning: z.string().nullish(), +}) + +/** + * ReleaseCheckName + */ +export const zReleaseCheckName = z.enum(['draft_valid', 'tested']) + +/** + * ReleaseCheckRow + */ +export const zReleaseCheckRow = z.object({ + detail: z.string(), + name: zReleaseCheckName, + passed: z.boolean(), +}) + +/** + * ReleaseState + */ +export const zReleaseState = z.object({ + service_api_enabled: z.boolean(), + webapp_enabled: z.boolean(), +}) + +/** + * RestoreResponse + */ +export const zRestoreResponse = z.object({ + draft_hash: z.string(), + result: z.literal('success'), +}) + /** * RevokeResponse */ @@ -630,6 +1453,16 @@ export const zRevokeResponse = z.object({ status: z.string(), }) +/** + * RunListQuery + */ +export const zRunListQuery = z.object({ + last_id: z.uuid().nullish(), + limit: z.int().gte(1).lte(100).optional().default(20), + status: z.enum(['failed', 'partial-succeeded', 'running', 'stopped', 'succeeded']).nullish(), + triggered_from: z.enum(['app-run', 'debugging']).optional().default('debugging'), +}) + /** * ServerVersionResponse * @@ -640,6 +1473,21 @@ export const zServerVersionResponse = z.object({ version: z.string(), }) +/** + * ServiceApi + */ +export const zServiceApi = z.object({ + base_url: z.string(), + enabled: z.boolean(), +}) + +/** + * ServiceApiPatch + */ +export const zServiceApiPatch = z.object({ + enabled: z.boolean(), +}) + /** * SessionListQuery * @@ -675,6 +1523,25 @@ export const zSessionListResponse = z.object({ total: z.int(), }) +/** + * SimpleAccountResponse + */ +export const zSimpleAccountResponse = z.object({ + email: z.string(), + id: z.string(), + name: z.string(), +}) + +/** + * SimpleEndUser + */ +export const zSimpleEndUser = z.object({ + id: z.string(), + is_anonymous: z.boolean(), + session_id: z.string().nullish(), + type: z.string(), +}) + /** * SimpleResultResponse */ @@ -746,6 +1613,142 @@ export const zTaskStopResponse = z.object({ result: z.literal('success'), }) +/** + * ToolCredentialCreatePayload + */ +export const zToolCredentialCreatePayload = z.object({ + credentials: z.record(z.string(), z.unknown()), + name: z.string().max(30).nullish(), +}) + +/** + * ToolCredentialUpdatePayload + */ +export const zToolCredentialUpdatePayload = z.object({ + credentials: z.record(z.string(), z.unknown()), + name: z.string().max(30).nullish(), +}) + +/** + * ToolInputType + * + * Binding modes for persisted tool input values. + */ +export const zToolInputType = z.enum(['constant', 'mixed', 'variable']) + +/** + * ToolInputValue + */ +export const zToolInputValue = z.object({ + type: zToolInputType.optional().default('constant'), + value: z.unknown().optional(), +}) + +/** + * ToolNodeTemplate + * + * A tool node's data with every value as {type, value}. + */ +export const zToolNodeTemplate = z.object({ + plugin_id: z.string().nullable(), + plugin_unique_identifier: z.string().nullable(), + provider_id: z.string(), + provider_name: z.string(), + provider_type: z.string(), + title: z.string(), + tool_configurations: z.record(z.string(), zToolInputValue), + tool_label: z.string(), + tool_name: z.string(), + tool_node_version: z.string().optional().default('2'), + tool_parameters: z.record(z.string(), zToolInputValue), + type: z.string().optional().default('tool'), +}) + +/** + * ToolParameterRow + */ +export const zToolParameterRow = z.object({ + default: z.unknown(), + form: z.string(), + label: z.string().nullable(), + llm_description: z.string().nullable(), + max: z.number().nullable(), + min: z.number().nullable(), + name: z.string(), + options: z.array(z.string()), + required: z.boolean(), + type: z.string(), +}) + +/** + * ToolProviderDetailResponse + */ +export const zToolProviderDetailResponse = z.object({ + configured: z.boolean(), + credential_form: z.array(zCredentialFormField), + credential_types: z.array(z.string()), + credentials: z.array(zCredentialRef), + default_credential: zCredentialRef.nullable(), + hints: z.array(zHint).optional(), + label: z.string().nullable(), + provider: z.string(), +}) + +/** + * ToolProviderRow + */ +export const zToolProviderRow = z.object({ + configured: z.boolean(), + credential_types: z.array(z.string()), + label: z.string().nullable(), + provider: z.string(), +}) + +/** + * ToolRow + */ +export const zToolRow = z.object({ + configured: z.boolean(), + description: z.string().nullable(), + label: z.string(), + name: z.string(), + node_data: zToolNodeTemplate, + parameters: z.array(zToolParameterRow), + provider: z.string(), + provider_label: z.string().nullable(), + provider_type: z.string(), +}) + +/** + * ToolListResponse + */ +export const zToolListResponse = z.object({ + data: z.array(zToolRow), + has_more: z.boolean(), + hints: z.array(zHint).optional(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * ToolSource + * + * Tool provider types a workflow tool node can call; values match the node's provider_type. + */ +export const zToolSource = z.enum(['api', 'builtin', 'mcp', 'workflow']) + +/** + * ToolListQuery + */ +export const zToolListQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), + provider: z.string().nullish(), + provider_type: zToolSource.nullish(), + query: z.string().optional().default(''), +}) + /** * UsageInfo */ @@ -763,6 +1766,130 @@ export const zMessageMetadata = z.object({ usage: zUsageInfo.nullish(), }) +/** + * VersionListQuery + */ +export const zVersionListQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + named_only: z.boolean().optional().default(false), + page: z.int().gte(1).optional().default(1), +}) + +/** + * VersionRow + */ +export const zVersionRow = z.object({ + created_at: z.int(), + created_by: z.string().nullish(), + current: z.boolean().optional().default(false), + id: z.string(), + marked_comment: z.string(), + marked_name: z.string(), +}) + +/** + * VersionListResponse + * + * Page of published versions, newest first; there is no total, `hints` carries the next page. + */ +export const zVersionListResponse = z.object({ + data: z.array(zVersionRow), + has_more: z.boolean(), + hints: z.array(zHint).optional(), + limit: z.int(), + page: z.int(), +}) + +/** + * WebApp + */ +export const zWebApp = z.object({ + access_token: z.string().nullish(), + app_base_url: z.string(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string().nullish(), + description: z.string().nullish(), + enabled: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + privacy_policy: z.string().nullish(), + title: z.string().nullish(), + url: z.string().nullish(), +}) + +/** + * WebAppAccess + */ +export const zWebAppAccess = z.object({ + access_mode: z.string(), +}) + +/** + * WebAppAccessMode + */ +export const zWebAppAccessMode = z.enum(['private', 'private_all', 'public', 'sso_verified']) + +/** + * WebAppAccessPayload + */ +export const zWebAppAccessPayload = z.object({ + access_mode: zWebAppAccessMode, +}) + +/** + * WebAppPatch + */ +export const zWebAppPatch = z.object({ + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string().nullish(), + description: z.string().nullish(), + enabled: z.boolean().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.enum(['emoji', 'image', 'link']).nullish(), + privacy_policy: z.string().nullish(), + title: z.string().nullish(), +}) + +/** + * WebAppToken + */ +export const zWebAppToken = z.object({ + access_token: z.string().nullish(), + app_base_url: z.string(), + url: z.string().nullish(), +}) + +/** + * WorkflowDiff + */ +export const zWorkflowDiff = z.object({ + edges_added: z.array(z.string()), + edges_removed: z.array(z.string()), + env_added: z.array(z.string()), + env_removed: z.array(z.string()), + features_changed: z.boolean(), + nodes_added: z.array(zNodeChange), + nodes_changed: z.array(zNodeChange), + nodes_removed: z.array(zNodeChange), + published: z.boolean(), +}) + +/** + * ReleaseCheckResponse + */ +export const zReleaseCheckResponse = z.object({ + changes: zWorkflowDiff, + checks: z.array(zReleaseCheckRow), + hints: z.array(zHint).optional(), + issues: z.array(zDslIssueRow), + ready: z.boolean(), + state: zReleaseState, +}) + /** * WorkflowRunData */ @@ -779,6 +1906,93 @@ export const zWorkflowRunData = z.object({ workflow_id: z.string(), }) +/** + * WorkflowRunDetailResponse + */ +export const zWorkflowRunDetailResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccountResponse.nullish(), + created_by_end_user: zSimpleEndUser.nullish(), + created_by_role: z.string().nullish(), + elapsed_time: z.number().nullish(), + error: z.string().nullish(), + exceptions_count: z.int().nullish(), + finished_at: z.int().nullish(), + graph: z.unknown(), + id: z.string(), + inputs: z.unknown(), + outputs: z.unknown(), + status: z.string().nullish(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + version: z.string().nullish(), +}) + +/** + * WorkflowRunForListResponse + */ +export const zWorkflowRunForListResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccountResponse.nullish(), + elapsed_time: z.number().nullish(), + exceptions_count: z.int().nullish(), + finished_at: z.int().nullish(), + id: z.string(), + retry_index: z.int().nullish(), + status: z.string().nullish(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + version: z.string().nullish(), +}) + +/** + * RunListResponse + * + * Cursor page of runs; `hints` carries the next page. + */ +export const zRunListResponse = z.object({ + data: z.array(zWorkflowRunForListResponse), + has_more: z.boolean(), + hints: z.array(zHint).optional(), + limit: z.int(), +}) + +/** + * WorkflowRunNodeExecutionResponse + */ +export const zWorkflowRunNodeExecutionResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccountResponse.nullish(), + created_by_end_user: zSimpleEndUser.nullish(), + created_by_role: z.string().nullish(), + elapsed_time: z.number().nullish(), + error: z.string().nullish(), + execution_metadata: z.unknown().optional(), + extras: z.unknown().optional(), + finished_at: z.int().nullish(), + id: z.string(), + index: z.int().nullish(), + inputs: z.unknown().optional(), + inputs_truncated: z.boolean().nullish(), + node_id: z.string().nullish(), + node_type: z.string().nullish(), + outputs: z.unknown().optional(), + outputs_truncated: z.boolean().nullish(), + predecessor_node_id: z.string().nullish(), + process_data: z.unknown().optional(), + process_data_truncated: z.boolean().nullish(), + retry_index: z.int().nullish(), + status: z.string().nullish(), + title: z.string().nullish(), +}) + +/** + * WorkflowRunNodeExecutionListResponse + */ +export const zWorkflowRunNodeExecutionListResponse = z.object({ + data: z.array(zWorkflowRunNodeExecutionResponse), +}) + /** * WorkflowRunPayload */ @@ -795,17 +2009,53 @@ export const zWorkflowRunPayload = z.object({ ]), ) .nullish(), - inputs: z.record(z.string(), z.unknown()), + inputs: z.record(z.string(), z.unknown()).optional(), workflow_id: z.string().nullish(), workspace_id: z.string().nullish(), }) +/** + * WorkflowWebApp + */ +export const zWorkflowWebApp = z.object({ + access_token: z.string().nullish(), + app_base_url: z.string(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string().nullish(), + description: z.string().nullish(), + enabled: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean().optional().default(false), + title: z.string().nullish(), + url: z.string().nullish(), +}) + +/** + * WorkflowWebAppPatch + */ +export const zWorkflowWebAppPatch = z.object({ + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string().nullish(), + description: z.string().nullish(), + enabled: z.boolean().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.enum(['emoji', 'image', 'link']).nullish(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean().nullish(), + title: z.string().nullish(), +}) + /** * WorkspaceDetailResponse */ export const zWorkspaceDetailResponse = z.object({ created_at: z.string().nullish(), - current: z.boolean(), id: z.string(), name: z.string(), role: z.string(), @@ -862,7 +2112,6 @@ export const zDeviceTokenResponse = z.object({ * WorkspaceSummaryResponse */ export const zWorkspaceSummaryResponse = z.object({ - current: z.boolean(), id: z.string(), name: z.string(), role: z.string(), @@ -962,6 +2211,126 @@ export const zPostAppsByAppIdAdvancedChatRunPath = z.object({ */ export const zPostAppsByAppIdAdvancedChatRunResponse = zEventStreamResponse +export const zGetAppsByAppIdAppInfoAdvancedChatPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zGetAppsByAppIdAppInfoAdvancedChatResponse = zChatAppInfo + +export const zPatchAppsByAppIdAppInfoAdvancedChatBody = zChatAppInfoPatch + +export const zPatchAppsByAppIdAppInfoAdvancedChatPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zPatchAppsByAppIdAppInfoAdvancedChatResponse = zChatAppInfo + +export const zGetAppsByAppIdAppInfoAgentPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zGetAppsByAppIdAppInfoAgentResponse = zAgentAppInfo + +export const zPatchAppsByAppIdAppInfoAgentBody = zAgentAppInfoPatch + +export const zPatchAppsByAppIdAppInfoAgentPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zPatchAppsByAppIdAppInfoAgentResponse = zAgentAppInfo + +export const zGetAppsByAppIdAppInfoAgentChatPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zGetAppsByAppIdAppInfoAgentChatResponse = zChatAppInfo + +export const zPatchAppsByAppIdAppInfoAgentChatBody = zChatAppInfoPatch + +export const zPatchAppsByAppIdAppInfoAgentChatPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zPatchAppsByAppIdAppInfoAgentChatResponse = zChatAppInfo + +export const zGetAppsByAppIdAppInfoChatPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zGetAppsByAppIdAppInfoChatResponse = zChatAppInfo + +export const zPatchAppsByAppIdAppInfoChatBody = zChatAppInfoPatch + +export const zPatchAppsByAppIdAppInfoChatPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zPatchAppsByAppIdAppInfoChatResponse = zChatAppInfo + +export const zGetAppsByAppIdAppInfoCompletionPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zGetAppsByAppIdAppInfoCompletionResponse = zAppSettingsInfo + +export const zPatchAppsByAppIdAppInfoCompletionBody = zAppInfoPatch + +export const zPatchAppsByAppIdAppInfoCompletionPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zPatchAppsByAppIdAppInfoCompletionResponse = zAppSettingsInfo + +export const zGetAppsByAppIdAppInfoWorkflowPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zGetAppsByAppIdAppInfoWorkflowResponse = zAppSettingsInfo + +export const zPatchAppsByAppIdAppInfoWorkflowBody = zAppInfoPatch + +export const zPatchAppsByAppIdAppInfoWorkflowPath = z.object({ + app_id: z.string(), +}) + +/** + * App info + */ +export const zPatchAppsByAppIdAppInfoWorkflowResponse = zAppSettingsInfo + export const zPostAppsByAppIdChatRunBody = zChatRunPayload export const zPostAppsByAppIdChatRunPath = z.object({ @@ -991,7 +2360,55 @@ export const zGetAppsByAppIdDependenciesCheckPath = z.object({ /** * Dependencies checked */ -export const zGetAppsByAppIdDependenciesCheckResponse = zCheckDependenciesResult +export const zGetAppsByAppIdDependenciesCheckResponse = zCheckDependenciesResponse + +export const zPostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunBody = zAdvancedChatNodeRunPayload + +export const zPostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunPath = z.object({ + app_id: z.string(), + node_id: z.string(), +}) + +/** + * Node execution + */ +export const zPostAppsByAppIdDraftAdvancedChatNodesByNodeIdRunResponse = + zWorkflowRunNodeExecutionResponse + +export const zPostAppsByAppIdDraftAdvancedChatRunBody = zChatRunPayload + +export const zPostAppsByAppIdDraftAdvancedChatRunPath = z.object({ + app_id: z.string(), +}) + +/** + * Run result (SSE stream) + */ +export const zPostAppsByAppIdDraftAdvancedChatRunResponse = zEventStreamResponse + +export const zPostAppsByAppIdDraftWorkflowNodesByNodeIdRunBody = zNodeRunPayload + +export const zPostAppsByAppIdDraftWorkflowNodesByNodeIdRunPath = z.object({ + app_id: z.string(), + node_id: z.string(), +}) + +/** + * Node execution + */ +export const zPostAppsByAppIdDraftWorkflowNodesByNodeIdRunResponse = + zWorkflowRunNodeExecutionResponse + +export const zPostAppsByAppIdDraftWorkflowRunBody = zDraftWorkflowRunPayload + +export const zPostAppsByAppIdDraftWorkflowRunPath = z.object({ + app_id: z.string(), +}) + +/** + * Run result (SSE stream) + */ +export const zPostAppsByAppIdDraftWorkflowRunResponse = zEventStreamResponse export const zGetAppsByAppIdDslPath = z.object({ app_id: z.string(), @@ -1007,6 +2424,37 @@ export const zGetAppsByAppIdDslQuery = z.object({ */ export const zGetAppsByAppIdDslResponse = zAppDslExportResponse +export const zGetAppsByAppIdEnvPath = z.object({ + app_id: z.string(), +}) + +/** + * Environment variables + */ +export const zGetAppsByAppIdEnvResponse = zEnvVariableListResponse + +export const zDeleteAppsByAppIdEnvByEnvIdPath = z.object({ + app_id: z.string(), + env_id: z.string(), +}) + +/** + * Variable removed + */ +export const zDeleteAppsByAppIdEnvByEnvIdResponse = zSimpleResultResponse + +export const zPutAppsByAppIdEnvByEnvIdBody = zEnvVariableSetPayload + +export const zPutAppsByAppIdEnvByEnvIdPath = z.object({ + app_id: z.string(), + env_id: z.string(), +}) + +/** + * Variable set + */ +export const zPutAppsByAppIdEnvByEnvIdResponse = zSimpleResultResponse + export const zPostAppsByAppIdFilesBody = zFileUploadPayload export const zPostAppsByAppIdFilesPath = z.object({ @@ -1040,6 +2488,91 @@ export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath = z.object({ */ export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse = zFormSubmitResponse +export const zGetAppsByAppIdReleaseCheckPath = z.object({ + app_id: z.string(), +}) + +/** + * Release check + */ +export const zGetAppsByAppIdReleaseCheckResponse = zReleaseCheckResponse + +export const zGetAppsByAppIdRunsPath = z.object({ + app_id: z.string(), +}) + +export const zGetAppsByAppIdRunsQuery = z.object({ + last_id: z.uuid().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + status: z.enum(['failed', 'partial-succeeded', 'running', 'stopped', 'succeeded']).optional(), + triggered_from: z.enum(['app-run', 'debugging']).optional().default('debugging'), +}) + +/** + * Run list + */ +export const zGetAppsByAppIdRunsResponse = zRunListResponse + +export const zGetAppsByAppIdRunsByRunIdPath = z.object({ + app_id: z.string(), + run_id: z.string(), +}) + +/** + * Run detail + */ +export const zGetAppsByAppIdRunsByRunIdResponse = zWorkflowRunDetailResponse + +export const zGetAppsByAppIdRunsByRunIdNodesPath = z.object({ + app_id: z.string(), + run_id: z.string(), +}) + +/** + * Node steps + */ +export const zGetAppsByAppIdRunsByRunIdNodesResponse = zWorkflowRunNodeExecutionListResponse + +export const zGetAppsByAppIdServiceApiPath = z.object({ + app_id: z.string(), +}) + +/** + * Service API + */ +export const zGetAppsByAppIdServiceApiResponse = zServiceApi + +export const zPatchAppsByAppIdServiceApiBody = zServiceApiPatch + +export const zPatchAppsByAppIdServiceApiPath = z.object({ + app_id: z.string(), +}) + +/** + * Service API + */ +export const zPatchAppsByAppIdServiceApiResponse = zServiceApi + +export const zGetAppsByAppIdServiceApiAgentPath = z.object({ + app_id: z.string(), +}) + +/** + * Service API + */ +export const zGetAppsByAppIdServiceApiAgentResponse = zAgentServiceApi + +export const zPatchAppsByAppIdServiceApiAgentBody = zServiceApiPatch + +export const zPatchAppsByAppIdServiceApiAgentPath = z.object({ + app_id: z.string(), +}) + +/** + * Service API + */ +export const zPatchAppsByAppIdServiceApiAgentResponse = zAgentServiceApi + export const zGetAppsByAppIdTasksByTaskIdEventsPath = z.object({ app_id: z.string(), task_id: z.string(), @@ -1065,6 +2598,209 @@ export const zPostAppsByAppIdTasksByTaskIdStopPath = z.object({ */ export const zPostAppsByAppIdTasksByTaskIdStopResponse = zTaskStopResponse +export const zGetAppsByAppIdVersionsPath = z.object({ + app_id: z.string(), +}) + +export const zGetAppsByAppIdVersionsQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + named_only: z.boolean().optional().default(false), + page: z.int().gte(1).optional().default(1), +}) + +/** + * Version list + */ +export const zGetAppsByAppIdVersionsResponse = zVersionListResponse + +export const zPostAppsByAppIdVersionsByVersionIdRestorePath = z.object({ + app_id: z.string(), + version_id: z.string(), +}) + +/** + * Restored into the draft + */ +export const zPostAppsByAppIdVersionsByVersionIdRestoreResponse = zRestoreResponse + +export const zGetAppsByAppIdWebappAccessPath = z.object({ + app_id: z.string(), +}) + +/** + * Web-app access + */ +export const zGetAppsByAppIdWebappAccessResponse = zWebAppAccess + +export const zPutAppsByAppIdWebappAccessBody = zWebAppAccessPayload + +export const zPutAppsByAppIdWebappAccessPath = z.object({ + app_id: z.string(), +}) + +/** + * Web-app access + */ +export const zPutAppsByAppIdWebappAccessResponse = zWebAppAccess + +export const zGetAppsByAppIdWebappAccessAgentPath = z.object({ + app_id: z.string(), +}) + +/** + * Web-app access + */ +export const zGetAppsByAppIdWebappAccessAgentResponse = zWebAppAccess + +export const zPutAppsByAppIdWebappAccessAgentBody = zWebAppAccessPayload + +export const zPutAppsByAppIdWebappAccessAgentPath = z.object({ + app_id: z.string(), +}) + +/** + * Web-app access + */ +export const zPutAppsByAppIdWebappAccessAgentResponse = zWebAppAccess + +export const zGetAppsByAppIdWebappAdvancedChatPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zGetAppsByAppIdWebappAdvancedChatResponse = zAdvancedChatWebApp + +export const zPatchAppsByAppIdWebappAdvancedChatBody = zAdvancedChatWebAppPatch + +export const zPatchAppsByAppIdWebappAdvancedChatPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zPatchAppsByAppIdWebappAdvancedChatResponse = zAdvancedChatWebApp + +export const zGetAppsByAppIdWebappAgentPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zGetAppsByAppIdWebappAgentResponse = zAgentWebApp + +export const zPatchAppsByAppIdWebappAgentBody = zChatWebAppPatch + +export const zPatchAppsByAppIdWebappAgentPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zPatchAppsByAppIdWebappAgentResponse = zAgentWebApp + +export const zGetAppsByAppIdWebappAgentChatPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zGetAppsByAppIdWebappAgentChatResponse = zChatWebApp + +export const zPatchAppsByAppIdWebappAgentChatBody = zChatWebAppPatch + +export const zPatchAppsByAppIdWebappAgentChatPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zPatchAppsByAppIdWebappAgentChatResponse = zChatWebApp + +export const zPostAppsByAppIdWebappAgentResetPath = z.object({ + app_id: z.string(), +}) + +/** + * New URL token + */ +export const zPostAppsByAppIdWebappAgentResetResponse = zWebAppToken + +export const zGetAppsByAppIdWebappChatPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zGetAppsByAppIdWebappChatResponse = zChatWebApp + +export const zPatchAppsByAppIdWebappChatBody = zChatWebAppPatch + +export const zPatchAppsByAppIdWebappChatPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zPatchAppsByAppIdWebappChatResponse = zChatWebApp + +export const zGetAppsByAppIdWebappCompletionPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zGetAppsByAppIdWebappCompletionResponse = zWebApp + +export const zPatchAppsByAppIdWebappCompletionBody = zWebAppPatch + +export const zPatchAppsByAppIdWebappCompletionPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zPatchAppsByAppIdWebappCompletionResponse = zWebApp + +export const zGetAppsByAppIdWebappWorkflowPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zGetAppsByAppIdWebappWorkflowResponse = zWorkflowWebApp + +export const zPatchAppsByAppIdWebappWorkflowBody = zWorkflowWebAppPatch + +export const zPatchAppsByAppIdWebappWorkflowPath = z.object({ + app_id: z.string(), +}) + +/** + * Web app + */ +export const zPatchAppsByAppIdWebappWorkflowResponse = zWorkflowWebApp + +export const zPostAppsByAppIdWebappResetPath = z.object({ + app_id: z.string(), +}) + +/** + * New URL token + */ +export const zPostAppsByAppIdWebappResetResponse = zWebAppToken + export const zPostAppsByAppIdWorkflowRunBody = zWorkflowRunPayload export const zPostAppsByAppIdWorkflowRunPath = z.object({ @@ -1076,6 +2812,31 @@ export const zPostAppsByAppIdWorkflowRunPath = z.object({ */ export const zPostAppsByAppIdWorkflowRunResponse = zEventStreamResponse +export const zPostAppsByAppIdPublishBody = zPublishPayload + +export const zPostAppsByAppIdPublishPath = z.object({ + app_id: z.string(), +}) + +/** + * Published + */ +export const zPostAppsByAppIdPublishResponse = zPublishResponse + +/** + * Node types + */ +export const zGetNodeTypesResponse = zNodeTypeListResponse + +export const zGetNodeTypesByNodeTypePath = z.object({ + node_type: z.string(), +}) + +/** + * Node type + */ +export const zGetNodeTypesByNodeTypeResponse = zNodeTypeDetailResponse + export const zPostOauthDeviceApproveBody = zDeviceMutateRequest /** @@ -1157,6 +2918,17 @@ export const zGetWorkspacesByWorkspaceIdPath = z.object({ */ export const zGetWorkspacesByWorkspaceIdResponse = zWorkspaceDetailResponse +export const zPostWorkspacesByWorkspaceIdAppsAdvancedChatBody = zCreateAppPayload + +export const zPostWorkspacesByWorkspaceIdAppsAdvancedChatPath = z.object({ + workspace_id: z.string(), +}) + +/** + * Created + */ +export const zPostWorkspacesByWorkspaceIdAppsAdvancedChatResponse = zCreatedAppResponse + export const zPostWorkspacesByWorkspaceIdAppsImportsBody = zAppDslImportPayload export const zPostWorkspacesByWorkspaceIdAppsImportsPath = z.object({ @@ -1178,6 +2950,61 @@ export const zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath = z.ob */ export const zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse = zImport +export const zPostWorkspacesByWorkspaceIdAppsImportsCheckBody = zDslCheckPayload + +export const zPostWorkspacesByWorkspaceIdAppsImportsCheckPath = z.object({ + workspace_id: z.string(), +}) + +/** + * Check result + */ +export const zPostWorkspacesByWorkspaceIdAppsImportsCheckResponse = zDslCheckResponse + +export const zPostWorkspacesByWorkspaceIdAppsWorkflowBody = zCreateAppPayload + +export const zPostWorkspacesByWorkspaceIdAppsWorkflowPath = z.object({ + workspace_id: z.string(), +}) + +/** + * Created + */ +export const zPostWorkspacesByWorkspaceIdAppsWorkflowResponse = zCreatedAppResponse + +export const zGetWorkspacesByWorkspaceIdKnowledgeBasesPath = z.object({ + workspace_id: z.string(), +}) + +export const zGetWorkspacesByWorkspaceIdKnowledgeBasesQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), + query: z.string().optional().default(''), +}) + +/** + * Knowledge bases + */ +export const zGetWorkspacesByWorkspaceIdKnowledgeBasesResponse = zKnowledgeBaseListResponse + +export const zGetWorkspacesByWorkspaceIdMarketplacePluginsPath = z.object({ + workspace_id: z.string(), +}) + +export const zGetWorkspacesByWorkspaceIdMarketplacePluginsQuery = z.object({ + category: z + .enum(['agent-strategy', 'datasource', 'extension', 'model', 'tool', 'trigger']) + .optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), + query: z.string().optional().default(''), +}) + +/** + * Marketplace plugins + */ +export const zGetWorkspacesByWorkspaceIdMarketplacePluginsResponse = zMarketplacePluginListResponse + export const zGetWorkspacesByWorkspaceIdMembersPath = z.object({ workspace_id: z.string(), }) @@ -1225,11 +3052,196 @@ export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdPath = z.object({ */ export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse = zMemberActionResponse -export const zPostWorkspacesByWorkspaceIdSwitchPath = z.object({ +export const zGetWorkspacesByWorkspaceIdModelProvidersByProviderPath = z.object({ + provider: z.string(), workspace_id: z.string(), }) /** - * Workspace detail + * Model provider */ -export const zPostWorkspacesByWorkspaceIdSwitchResponse = zWorkspaceDetailResponse +export const zGetWorkspacesByWorkspaceIdModelProvidersByProviderResponse = + zModelProviderDetailResponse + +export const zPostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsBody = + zProviderCredentialCreatePayload + +export const zPostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsPath = z.object({ + provider: z.string(), + workspace_id: z.string(), +}) + +/** + * Credential saved + */ +export const zPostWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsResponse = + zCredentialWriteResponse + +export const zPatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdBody = + zProviderCredentialUpdatePayload + +export const zPatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdPath = + z.object({ + credential_id: z.string(), + provider: z.string(), + workspace_id: z.string(), + }) + +/** + * Credential replaced + */ +export const zPatchWorkspacesByWorkspaceIdModelProvidersByProviderCredentialsByCredentialIdResponse = + zCredentialWriteResponse + +export const zPostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsBody = + zModelCredentialCreatePayload + +export const zPostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsPath = z.object({ + provider: z.string(), + workspace_id: z.string(), +}) + +/** + * Credential saved + */ +export const zPostWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsResponse = + zCredentialWriteResponse + +export const zPatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdBody = + zModelCredentialUpdatePayload + +export const zPatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdPath = + z.object({ + credential_id: z.string(), + provider: z.string(), + workspace_id: z.string(), + }) + +/** + * Credential replaced + */ +export const zPatchWorkspacesByWorkspaceIdModelProvidersByProviderModelsCredentialsByCredentialIdResponse = + zCredentialWriteResponse + +export const zGetWorkspacesByWorkspaceIdModelsPath = z.object({ + workspace_id: z.string(), +}) + +export const zGetWorkspacesByWorkspaceIdModelsQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + model_type: z + .enum(['llm', 'moderation', 'rerank', 'speech2text', 'text-embedding', 'tts']) + .optional(), + page: z.int().gte(1).optional().default(1), + provider: z.string().optional(), + query: z.string().optional().default(''), +}) + +/** + * Models + */ +export const zGetWorkspacesByWorkspaceIdModelsResponse = zModelListResponse + +export const zGetWorkspacesByWorkspaceIdPluginTasksByTaskIdPath = z.object({ + task_id: z.string(), + workspace_id: z.string(), +}) + +/** + * Install task + */ +export const zGetWorkspacesByWorkspaceIdPluginTasksByTaskIdResponse = zPluginTaskResponse + +export const zGetWorkspacesByWorkspaceIdPluginsPath = z.object({ + workspace_id: z.string(), +}) + +export const zGetWorkspacesByWorkspaceIdPluginsQuery = z.object({ + category: z + .enum(['agent-strategy', 'datasource', 'extension', 'model', 'tool', 'trigger']) + .optional(), +}) + +/** + * Installed plugins + */ +export const zGetWorkspacesByWorkspaceIdPluginsResponse = zPluginListResponse + +export const zDeleteWorkspacesByWorkspaceIdPluginsByPluginIdPath = z.object({ + plugin_id: z.string(), + workspace_id: z.string(), +}) + +/** + * Plugin uninstalled + */ +export const zDeleteWorkspacesByWorkspaceIdPluginsByPluginIdResponse = zPluginDeleteResponse + +export const zPostWorkspacesByWorkspaceIdPluginsInstallBody = zPluginInstallPayload + +export const zPostWorkspacesByWorkspaceIdPluginsInstallPath = z.object({ + workspace_id: z.string(), +}) + +/** + * Install started + */ +export const zPostWorkspacesByWorkspaceIdPluginsInstallResponse = zPluginTaskStartResponse + +export const zGetWorkspacesByWorkspaceIdToolProvidersByProviderPath = z.object({ + provider: z.string(), + workspace_id: z.string(), +}) + +/** + * Tool provider + */ +export const zGetWorkspacesByWorkspaceIdToolProvidersByProviderResponse = + zToolProviderDetailResponse + +export const zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsBody = + zToolCredentialCreatePayload + +export const zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsPath = z.object({ + provider: z.string(), + workspace_id: z.string(), +}) + +/** + * Credential saved + */ +export const zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsResponse = + zCredentialWriteResponse + +export const zPatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdBody = + zToolCredentialUpdatePayload + +export const zPatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdPath = + z.object({ + credential_id: z.string(), + provider: z.string(), + workspace_id: z.string(), + }) + +/** + * Credential replaced + */ +export const zPatchWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsByCredentialIdResponse = + zCredentialWriteResponse + +export const zGetWorkspacesByWorkspaceIdToolsPath = z.object({ + workspace_id: z.string(), +}) + +export const zGetWorkspacesByWorkspaceIdToolsQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), + provider: z.string().optional(), + provider_type: z.enum(['api', 'builtin', 'mcp', 'workflow']).optional(), + query: z.string().optional().default(''), +}) + +/** + * Tools + */ +export const zGetWorkspacesByWorkspaceIdToolsResponse = zToolListResponse