mirror of
https://github.com/langgenius/dify.git
synced 2026-07-04 04:28:55 +08:00
Compare commits
8 Commits
refactor/o
...
laipz8200/
| Author | SHA1 | Date | |
|---|---|---|---|
| 667e97f3fc | |||
| 0cf123784b | |||
| 8b8a586410 | |||
| ba0fbda94d | |||
| 6a22cb1f13 | |||
| 34f0990f8e | |||
| 184178adb8 | |||
| 095fe6221e |
@ -148,7 +148,9 @@ class ChatMessageTextApi(Resource):
|
||||
@get_app_model
|
||||
def post(self, app_model: App):
|
||||
try:
|
||||
payload = TextToSpeechPayload.model_validate(console_ns.payload)
|
||||
payload_data = dict(console_ns.payload or {})
|
||||
payload_data.setdefault("text", "")
|
||||
payload = TextToSpeechPayload.model_validate(payload_data)
|
||||
message_ref = None
|
||||
if payload.message_id:
|
||||
app_ref = AppRefService.create_app_ref(app_model)
|
||||
|
||||
@ -2,13 +2,11 @@ from flask import Blueprint
|
||||
from flask_restx import Namespace
|
||||
|
||||
from controllers.openapi._errors import ErrorBody, OpenApiErrorCode, OpenApiErrorFormatter
|
||||
from controllers.openapi._version_gate import attach_version_gate
|
||||
from libs.device_flow_security import attach_anti_framing
|
||||
from libs.external_api import ExternalApi
|
||||
|
||||
bp = Blueprint("openapi", __name__, url_prefix="/openapi/v1")
|
||||
attach_anti_framing(bp)
|
||||
attach_version_gate(bp)
|
||||
|
||||
api = ExternalApi(
|
||||
bp,
|
||||
|
||||
@ -45,7 +45,6 @@ class OpenApiErrorCode(StrEnum):
|
||||
TOO_MANY_REQUESTS = "too_many_requests"
|
||||
INTERNAL_ERROR = "internal_server_error"
|
||||
BAD_GATEWAY = "bad_gateway"
|
||||
UPGRADE_REQUIRED = "upgrade_required"
|
||||
UNKNOWN = "unknown"
|
||||
# domain codes (must match the error_code attribute of the exception
|
||||
# classes raised on the openapi surface)
|
||||
|
||||
@ -279,7 +279,7 @@ def _csv_string_query_schema(schema: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
class AppDescribeQuery(BaseModel):
|
||||
"""`?fields=` allow-list for GET /apps/<id>.
|
||||
"""`?fields=` allow-list for GET /apps/<id>/describe.
|
||||
|
||||
Empty / omitted → all blocks. Unknown member → ValidationError → 422.
|
||||
"""
|
||||
@ -441,7 +441,7 @@ class MemberActionResponse(BaseModel):
|
||||
|
||||
|
||||
class TaskStopResponse(BaseModel):
|
||||
"""200 body for POST /apps/<id>/tasks/<task_id>:stop. The handler always returns
|
||||
"""200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns
|
||||
{"result": "success"}, so `result` is required (no default) — the generated contract
|
||||
types it as a required `'success'` rather than an optional field."""
|
||||
|
||||
@ -473,7 +473,7 @@ class AppDslImportPayload(BaseModel):
|
||||
|
||||
|
||||
class AppDslExportQuery(BaseModel):
|
||||
"""Query parameters for GET /apps/<app_id>/dsl."""
|
||||
"""Query parameters for GET /apps/<app_id>/export."""
|
||||
|
||||
include_secret: bool = Field(False, description="Include encrypted secret values in the exported DSL")
|
||||
workflow_id: UUIDStr | None = Field(
|
||||
@ -488,7 +488,7 @@ class AppDslExportResponse(BaseModel):
|
||||
|
||||
|
||||
class FormSubmitResponse(BaseModel):
|
||||
"""Empty 200 body for POST /apps/<id>/human-input-forms/<token>:submit. `extra='forbid'`
|
||||
"""Empty 200 body for POST /apps/<id>/form/human_input/<token>. `extra='forbid'`
|
||||
pins `additionalProperties: false` so the generated contract is an exact `{}` rather
|
||||
than an under-annotated open object."""
|
||||
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
"""Version gate: reject outdated difyctl clients on /openapi/v1 with HTTP 426.
|
||||
|
||||
difyctl and the ``/openapi/v1`` surface ship in lockstep. A breaking path change
|
||||
(resource-oriented paths) means an outdated difyctl calls removed paths and would
|
||||
otherwise get a bare 404. This gate reads the difyctl version from the User-Agent
|
||||
and returns ``426 Upgrade Required`` with an upgrade hint when the client is older
|
||||
than the minimum this server supports.
|
||||
|
||||
Design record: dify-todo/inprogress/difyctl/openapi/version.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
from flask import Blueprint, Response, request
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from controllers.openapi._errors import ErrorBody, OpenApiErrorCode
|
||||
|
||||
# Oldest difyctl this server serves. Bumped in lockstep with breaking
|
||||
# /openapi/v1 changes (paired with difyctl's own version + its MIN_DIFY_VERSION).
|
||||
MIN_DIFYCTL_VERSION: Final = "0.2.0-alpha"
|
||||
|
||||
_UPGRADE_HINT: Final = "Upgrade difyctl: https://docs.dify.ai/en/cli/install"
|
||||
|
||||
# difyctl sends `User-Agent: difyctl/<semver> (<os>; <arch>; <channel>)`.
|
||||
_DIFYCTL_UA_RE = re.compile(r"^difyctl/(\d+\.\d+\.\d+(?:-[\w.]+)?)")
|
||||
|
||||
_PREFIX: Final = "/openapi/v1/"
|
||||
|
||||
# Paths a too-old client must still reach to discover that it is outdated.
|
||||
_ALLOWLIST: Final = frozenset({"/openapi/v1/_version", "/openapi/v1/_health"})
|
||||
|
||||
|
||||
def _upgrade_required_response(client_version: str) -> Response:
|
||||
body = ErrorBody(
|
||||
code=OpenApiErrorCode.UPGRADE_REQUIRED,
|
||||
message=f"difyctl {client_version} is no longer supported; upgrade to >= {MIN_DIFYCTL_VERSION}.",
|
||||
status=426,
|
||||
hint=_UPGRADE_HINT,
|
||||
)
|
||||
return Response(body.model_dump_json(exclude_none=True), status=426, mimetype="application/json")
|
||||
|
||||
|
||||
def attach_version_gate(bp: Blueprint) -> None:
|
||||
"""Reject difyctl clients older than ``MIN_DIFYCTL_VERSION`` with 426.
|
||||
|
||||
Registered app-wide (``before_app_request``) rather than blueprint-scoped so it
|
||||
also fires for requests to *removed* paths — those no longer match an openapi
|
||||
route and would 404 before a blueprint-scoped ``before_request`` ever runs. The
|
||||
prefix guard scopes it back to ``/openapi/v1``. Fails open for non-difyctl or
|
||||
unparseable User-Agents (only a confidently-too-old difyctl is blocked).
|
||||
"""
|
||||
|
||||
@bp.before_app_request
|
||||
def _enforce_min_client_version() -> Response | None: # pyright: ignore[reportUnusedFunction]
|
||||
if not request.path.startswith(_PREFIX):
|
||||
return None
|
||||
if request.path in _ALLOWLIST:
|
||||
return None
|
||||
match = _DIFYCTL_UA_RE.match(request.headers.get("User-Agent", ""))
|
||||
if match is None:
|
||||
return None
|
||||
try:
|
||||
client_version = Version(match.group(1))
|
||||
except InvalidVersion:
|
||||
return None
|
||||
if client_version < Version(MIN_DIFYCTL_VERSION):
|
||||
return _upgrade_required_response(match.group(1))
|
||||
return None
|
||||
@ -30,7 +30,7 @@ class AppDslImportApi(Resource):
|
||||
a new app.
|
||||
|
||||
Returns 202 when the DSL version requires an explicit confirmation step
|
||||
(major version mismatch). Callers must then POST to the imports :confirm method.
|
||||
(major version mismatch). Callers must then POST to the confirm endpoint.
|
||||
Returns 400 when the import failed due to invalid DSL or a business error.
|
||||
"""
|
||||
|
||||
@ -79,7 +79,7 @@ class AppDslImportApi(Resource):
|
||||
return result, 200
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports/<string:import_id>:confirm")
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports/<string:import_id>/confirm")
|
||||
class AppDslImportConfirmApi(Resource):
|
||||
"""Confirm a pending DSL import identified by ``import_id``.
|
||||
|
||||
@ -119,7 +119,7 @@ class AppDslImportConfirmApi(Resource):
|
||||
return result, 200
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/dsl")
|
||||
@openapi_ns.route("/apps/<string:app_id>/export")
|
||||
class AppDslExportApi(Resource):
|
||||
"""Export an app's current draft configuration as a DSL YAML string.
|
||||
|
||||
@ -153,7 +153,7 @@ class AppDslExportApi(Resource):
|
||||
return AppDslExportResponse(data=data), 200
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/dependencies:check")
|
||||
@openapi_ns.route("/apps/<string:app_id>/check-dependencies")
|
||||
class AppDslCheckDependenciesApi(Resource):
|
||||
"""Check for leaked plugin dependencies after a DSL import.
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""POST /openapi/v1/apps/<app_id>:run — mode-agnostic runner."""
|
||||
"""POST /openapi/v1/apps/<app_id>/run — mode-agnostic runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -138,7 +138,7 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest, Session], Any]] = {
|
||||
}
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>:run")
|
||||
@openapi_ns.route("/apps/<string:app_id>/run")
|
||||
class AppRunApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_RUN,
|
||||
@ -174,7 +174,7 @@ class AppRunApi(Resource):
|
||||
return helper.compact_generate_response(stream_obj)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>:stop")
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>/stop")
|
||||
class AppRunTaskStopApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_RUN,
|
||||
|
||||
@ -129,7 +129,7 @@ def build_app_describe_response(app: App, fields: set[str] | None) -> AppDescrib
|
||||
return AppDescribeResponse(info=info, parameters=parameters, input_schema=input_schema)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>")
|
||||
@openapi_ns.route("/apps/<string:app_id>/describe")
|
||||
class AppDescribeApi(AppReadResource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_READ,
|
||||
|
||||
@ -87,7 +87,7 @@ class PermittedExternalAppsListApi(Resource):
|
||||
return env
|
||||
|
||||
|
||||
@openapi_ns.route("/permitted-external-apps/<string:app_id>")
|
||||
@openapi_ns.route("/permitted-external-apps/<string:app_id>/describe")
|
||||
class PermittedExternalAppDescribeApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_READ_PERMITTED_EXTERNAL,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""POST /openapi/v1/apps/<app_id>/files — upload a file for use in app inputs."""
|
||||
"""POST /openapi/v1/apps/<app_id>/files/upload — upload a file for use in app inputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -26,7 +26,7 @@ from libs.oauth_bearer import Scope
|
||||
from services.file_service import FileService
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/files")
|
||||
@openapi_ns.route("/apps/<string:app_id>/files/upload")
|
||||
class AppFileUploadApi(Resource):
|
||||
@openapi_ns.doc("upload_file_for_app_input")
|
||||
@openapi_ns.doc(description="Upload a file to use as an input variable when running the app")
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"""
|
||||
OpenAPI bearer-authed human input form endpoints.
|
||||
|
||||
GET /apps/<app_id>/human-input-forms/<form_token> — fetch paused form definition
|
||||
POST /apps/<app_id>/human-input-forms/<form_token>:submit — submit form response
|
||||
GET /apps/<app_id>/form/human_input/<form_token> — fetch paused form definition
|
||||
POST /apps/<app_id>/form/human_input/<form_token> — submit form response
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -60,7 +60,7 @@ def _ensure_form_is_allowed_for_openapi(form) -> None:
|
||||
raise RecipientSurfaceMismatch()
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>")
|
||||
@openapi_ns.route("/apps/<string:app_id>/form/human_input/<string:form_token>")
|
||||
class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@openapi_ns.response(200, "Form definition", openapi_ns.models[HumanInputFormDefinitionResponse.__name__])
|
||||
@auth_router.guard(
|
||||
@ -79,9 +79,6 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
service.ensure_form_active(form)
|
||||
return _jsonify_form_definition(form)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>:submit")
|
||||
class OpenApiWorkflowHumanInputFormSubmitApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_RUN,
|
||||
rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN),
|
||||
|
||||
@ -113,7 +113,7 @@ class WorkspaceByIdApi(Resource):
|
||||
return _workspace_detail(tenant, membership)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>:switch")
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/switch")
|
||||
class WorkspaceSwitchApi(Resource):
|
||||
"""Server-side switch — equivalent to the console's POST /workspaces/switch.
|
||||
|
||||
@ -212,12 +212,11 @@ class WorkspaceMembersApi(Resource):
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>")
|
||||
class WorkspaceMemberApi(Resource):
|
||||
"""Remove a member (DELETE) or change a member's role (PATCH).
|
||||
"""Remove a member.
|
||||
|
||||
Self-removal and owner-removal are explicitly rejected by the service
|
||||
layer (CannotOperateSelfError, NoPermissionError) — both surface as
|
||||
400 per the spec, with the service's message preserved. Owner can never be
|
||||
assigned via PATCH (closed enum); admin cannot demote the standing owner.
|
||||
400 per the spec, with the service's message preserved.
|
||||
"""
|
||||
|
||||
@auth_router.guard_workspace(
|
||||
@ -244,6 +243,15 @@ class WorkspaceMemberApi(Resource):
|
||||
|
||||
return MemberActionResponse()
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>/role")
|
||||
class WorkspaceMemberRoleApi(Resource):
|
||||
"""Change a member's role.
|
||||
|
||||
Owner cannot be assigned here (closed enum). Admin cannot demote the
|
||||
standing owner (service NoPermissionError → 400, per spec).
|
||||
"""
|
||||
|
||||
@auth_router.guard_workspace(
|
||||
scope=Scope.WORKSPACE_WRITE,
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
@ -251,7 +259,7 @@ class WorkspaceMemberApi(Resource):
|
||||
)
|
||||
@returns(200, MemberActionResponse, description="Role updated")
|
||||
@accepts(body=MemberRoleUpdatePayload)
|
||||
def patch(self, workspace_id: str, member_id: str, *, auth_data: AuthData, body: MemberRoleUpdatePayload):
|
||||
def put(self, workspace_id: str, member_id: str, *, auth_data: AuthData, body: MemberRoleUpdatePayload):
|
||||
operator = _load_account(auth_data.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
member = AccountService.get_account_by_id(db.session, member_id)
|
||||
|
||||
@ -15,8 +15,8 @@ from core.app.app_config.easy_ui_based_app.model_config.converter import ModelCo
|
||||
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.apps.completion.app_config_manager import CompletionAppConfigManager
|
||||
from core.app.apps.completion.app_runner import CompletionAppRunner
|
||||
from core.app.apps.completion.generate_response_converter import CompletionAppGenerateResponseConverter
|
||||
from core.app.apps.completion.workflow_runner import CompletionWorkflowRunner
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
|
||||
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
|
||||
@ -225,7 +225,7 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
|
||||
message = self._get_message(message_id)
|
||||
|
||||
# chatbot app
|
||||
runner = CompletionAppRunner()
|
||||
runner = CompletionWorkflowRunner()
|
||||
runner.run(
|
||||
session=session,
|
||||
application_generate_entity=application_generate_entity,
|
||||
|
||||
@ -1,206 +0,0 @@
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.apps.base_app_runner import AppRunner
|
||||
from core.app.apps.completion.app_config_manager import CompletionAppConfig
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
CompletionAppGenerateEntity,
|
||||
)
|
||||
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
|
||||
from core.db.session_factory import create_session
|
||||
from core.model_manager import ModelInstance
|
||||
from core.moderation.base import ModerationError
|
||||
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
|
||||
from extensions.ext_database import db
|
||||
from graphon.file import File
|
||||
from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent
|
||||
from models.model import App, Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompletionAppRunner(AppRunner):
|
||||
"""
|
||||
Completion Application Runner
|
||||
"""
|
||||
|
||||
def run(
|
||||
self,
|
||||
session: Session,
|
||||
application_generate_entity: CompletionAppGenerateEntity,
|
||||
queue_manager: AppQueueManager,
|
||||
message: Message,
|
||||
):
|
||||
"""
|
||||
Run application
|
||||
:param application_generate_entity: application generate entity
|
||||
:param queue_manager: application queue manager
|
||||
:param message: message
|
||||
:return:
|
||||
"""
|
||||
app_config = application_generate_entity.app_config
|
||||
app_config = cast(CompletionAppConfig, app_config)
|
||||
stmt = select(App).where(App.id == app_config.app_id)
|
||||
with create_session() as session:
|
||||
app_record = session.scalar(stmt)
|
||||
if app_record:
|
||||
session.expunge(app_record)
|
||||
if not app_record:
|
||||
raise ValueError("App not found")
|
||||
|
||||
inputs = application_generate_entity.inputs
|
||||
query = application_generate_entity.query
|
||||
files = application_generate_entity.files
|
||||
|
||||
image_detail_config = (
|
||||
application_generate_entity.file_upload_config.image_config.detail
|
||||
if (
|
||||
application_generate_entity.file_upload_config
|
||||
and application_generate_entity.file_upload_config.image_config
|
||||
)
|
||||
else None
|
||||
)
|
||||
image_detail_config = image_detail_config or ImagePromptMessageContent.DETAIL.LOW
|
||||
|
||||
# organize all inputs and template to prompt messages
|
||||
# Include: prompt template, inputs, query(optional), files(optional)
|
||||
prompt_messages, stop = self.organize_prompt_messages(
|
||||
app_record=app_record,
|
||||
model_config=application_generate_entity.model_conf,
|
||||
prompt_template_entity=app_config.prompt_template,
|
||||
inputs=inputs,
|
||||
files=files,
|
||||
query=query,
|
||||
image_detail_config=image_detail_config,
|
||||
)
|
||||
|
||||
# moderation
|
||||
try:
|
||||
# process sensitive_word_avoidance
|
||||
_, inputs, query = self.moderation_for_inputs(
|
||||
app_id=app_record.id,
|
||||
tenant_id=app_config.tenant_id,
|
||||
app_generate_entity=application_generate_entity,
|
||||
inputs=inputs,
|
||||
query=query or "",
|
||||
message_id=message.id,
|
||||
)
|
||||
except ModerationError as e:
|
||||
self.direct_output(
|
||||
queue_manager=queue_manager,
|
||||
app_generate_entity=application_generate_entity,
|
||||
prompt_messages=prompt_messages,
|
||||
text=str(e),
|
||||
stream=application_generate_entity.stream,
|
||||
)
|
||||
return
|
||||
|
||||
# fill in variable inputs from external data tools if exists
|
||||
external_data_tools = app_config.external_data_variables
|
||||
if external_data_tools:
|
||||
inputs = self.fill_in_inputs_from_external_data_tools(
|
||||
tenant_id=app_record.tenant_id,
|
||||
app_id=app_record.id,
|
||||
external_data_tools=external_data_tools,
|
||||
inputs=inputs,
|
||||
query=query,
|
||||
)
|
||||
|
||||
# get context from datasets
|
||||
context = None
|
||||
context_files: list[File] = []
|
||||
if app_config.dataset and app_config.dataset.dataset_ids:
|
||||
hit_callback = DatasetIndexToolCallbackHandler(
|
||||
queue_manager,
|
||||
app_record.id,
|
||||
message.id,
|
||||
application_generate_entity.user_id,
|
||||
application_generate_entity.invoke_from,
|
||||
)
|
||||
|
||||
dataset_config = app_config.dataset
|
||||
if dataset_config and dataset_config.retrieve_config.query_variable:
|
||||
query = inputs.get(dataset_config.retrieve_config.query_variable, "")
|
||||
|
||||
dataset_retrieval = DatasetRetrieval(application_generate_entity)
|
||||
context, retrieved_files = dataset_retrieval.retrieve(
|
||||
session=session,
|
||||
app_id=app_record.id,
|
||||
user_id=application_generate_entity.user_id,
|
||||
tenant_id=app_record.tenant_id,
|
||||
model_config=application_generate_entity.model_conf,
|
||||
config=dataset_config,
|
||||
query=query or "",
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
show_retrieve_source=app_config.additional_features.show_retrieve_source
|
||||
if app_config.additional_features
|
||||
else False,
|
||||
hit_callback=hit_callback,
|
||||
message_id=message.id,
|
||||
inputs=inputs,
|
||||
vision_enabled=bool(
|
||||
application_generate_entity.app_config.app_model_config_dict.get("file_upload", {})
|
||||
.get("image", {})
|
||||
.get("enabled", False)
|
||||
),
|
||||
)
|
||||
context_files = retrieved_files or []
|
||||
|
||||
# reorganize all inputs and template to prompt messages
|
||||
# Include: prompt template, inputs, query(optional), files(optional)
|
||||
# memory(optional), external data, dataset context(optional)
|
||||
prompt_messages, stop = self.organize_prompt_messages(
|
||||
app_record=app_record,
|
||||
model_config=application_generate_entity.model_conf,
|
||||
prompt_template_entity=app_config.prompt_template,
|
||||
inputs=inputs,
|
||||
files=files,
|
||||
query=query,
|
||||
context=context,
|
||||
image_detail_config=image_detail_config,
|
||||
context_files=context_files,
|
||||
)
|
||||
|
||||
# check hosting moderation
|
||||
hosting_moderation_result = self.check_hosting_moderation(
|
||||
application_generate_entity=application_generate_entity,
|
||||
queue_manager=queue_manager,
|
||||
prompt_messages=prompt_messages,
|
||||
)
|
||||
|
||||
if hosting_moderation_result:
|
||||
return
|
||||
|
||||
# Re-calculate the max tokens if sum(prompt_token + max_tokens) over model token limit
|
||||
self.recalc_llm_max_tokens(model_config=application_generate_entity.model_conf, prompt_messages=prompt_messages)
|
||||
|
||||
# Invoke model
|
||||
model_instance = ModelInstance(
|
||||
provider_model_bundle=application_generate_entity.model_conf.provider_model_bundle,
|
||||
model=application_generate_entity.model_conf.model,
|
||||
)
|
||||
|
||||
# Release the Flask scoped session before LLM streaming so a checked-out DB connection
|
||||
# is not held for the lifetime of the provider response.
|
||||
db.session.close()
|
||||
|
||||
invoke_result = model_instance.invoke_llm(
|
||||
prompt_messages=prompt_messages,
|
||||
model_parameters=application_generate_entity.model_conf.parameters,
|
||||
stop=stop,
|
||||
stream=application_generate_entity.stream,
|
||||
)
|
||||
|
||||
# handle invoke result
|
||||
self._handle_invoke_result(
|
||||
invoke_result=invoke_result,
|
||||
queue_manager=queue_manager,
|
||||
stream=application_generate_entity.stream,
|
||||
message_id=message.id,
|
||||
user_id=application_generate_entity.user_id,
|
||||
tenant_id=app_config.tenant_id,
|
||||
)
|
||||
148
api/core/app/apps/completion/graph_event_adapter.py
Normal file
148
api/core/app/apps/completion/graph_event_adapter.py
Normal file
@ -0,0 +1,148 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.entities.app_invoke_entities import CompletionAppGenerateEntity
|
||||
from core.app.entities.queue_entities import (
|
||||
QueueErrorEvent,
|
||||
QueueLLMChunkEvent,
|
||||
QueueMessageEndEvent,
|
||||
QueueRetrieverResourcesEvent,
|
||||
QueueStopEvent,
|
||||
)
|
||||
from core.prompt.utils.prompt_message_util import SavedPrompt
|
||||
from core.rag.entities import RetrievalSourceMetadata
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from graphon.graph_events import (
|
||||
GraphEngineEvent,
|
||||
GraphRunAbortedEvent,
|
||||
GraphRunFailedEvent,
|
||||
GraphRunSucceededEvent,
|
||||
NodeRunExceptionEvent,
|
||||
NodeRunFailedEvent,
|
||||
NodeRunRetrieverResourceEvent,
|
||||
NodeRunStreamChunkEvent,
|
||||
NodeRunSucceededEvent,
|
||||
)
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
|
||||
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
|
||||
|
||||
_LLM_TEXT_SELECTOR_PREFIX = ("llm", "text")
|
||||
|
||||
|
||||
class CompletionGraphEventAdapter:
|
||||
"""Translate runtime workflow events into legacy Completion queue events."""
|
||||
|
||||
_application_generate_entity: CompletionAppGenerateEntity
|
||||
_queue_manager: AppQueueManager
|
||||
_answer: str
|
||||
_usage: LLMUsage
|
||||
_saved_prompt: list[SavedPrompt]
|
||||
_chunk_index: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
application_generate_entity: CompletionAppGenerateEntity,
|
||||
queue_manager: AppQueueManager,
|
||||
) -> None:
|
||||
self._application_generate_entity = application_generate_entity
|
||||
self._queue_manager = queue_manager
|
||||
self._answer = ""
|
||||
self._usage = LLMUsage.empty_usage()
|
||||
self._saved_prompt = []
|
||||
self._chunk_index = 0
|
||||
|
||||
def handle_event(self, event: GraphEngineEvent) -> None:
|
||||
match event:
|
||||
case NodeRunStreamChunkEvent():
|
||||
self._handle_stream_chunk(event)
|
||||
case NodeRunRetrieverResourceEvent():
|
||||
self._handle_retriever_resource(event)
|
||||
case NodeRunSucceededEvent():
|
||||
self._handle_node_succeeded(event)
|
||||
case NodeRunFailedEvent() | NodeRunExceptionEvent():
|
||||
self._publish_error(event.error or event.node_run_result.error or "Node failed")
|
||||
case GraphRunSucceededEvent():
|
||||
self._publish_message_end(event.outputs)
|
||||
case GraphRunFailedEvent():
|
||||
self._publish_error(event.error)
|
||||
case GraphRunAbortedEvent():
|
||||
self._queue_manager.publish(
|
||||
QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL),
|
||||
PublishFrom.APPLICATION_MANAGER,
|
||||
)
|
||||
case _:
|
||||
return
|
||||
|
||||
def _handle_stream_chunk(self, event: NodeRunStreamChunkEvent) -> None:
|
||||
if tuple(event.selector)[:2] != _LLM_TEXT_SELECTOR_PREFIX:
|
||||
return
|
||||
if event.is_final and not event.chunk:
|
||||
return
|
||||
|
||||
self._answer += event.chunk
|
||||
self._queue_manager.publish(
|
||||
QueueLLMChunkEvent(
|
||||
chunk=LLMResultChunk(
|
||||
model=self._application_generate_entity.model_conf.model,
|
||||
prompt_messages=[],
|
||||
delta=LLMResultChunkDelta(
|
||||
index=self._chunk_index,
|
||||
message=AssistantPromptMessage(content=event.chunk),
|
||||
),
|
||||
)
|
||||
),
|
||||
PublishFrom.APPLICATION_MANAGER,
|
||||
)
|
||||
self._chunk_index += 1
|
||||
|
||||
def _handle_retriever_resource(self, event: NodeRunRetrieverResourceEvent) -> None:
|
||||
self._queue_manager.publish(
|
||||
QueueRetrieverResourcesEvent(
|
||||
retriever_resources=[
|
||||
RetrievalSourceMetadata.model_validate(resource) for resource in event.retriever_resources
|
||||
],
|
||||
in_iteration_id=event.in_iteration_id,
|
||||
in_loop_id=event.in_loop_id,
|
||||
),
|
||||
PublishFrom.APPLICATION_MANAGER,
|
||||
)
|
||||
|
||||
def _handle_node_succeeded(self, event: NodeRunSucceededEvent) -> None:
|
||||
if event.node_type != BuiltinNodeTypes.LLM and event.node_id != "llm":
|
||||
return
|
||||
|
||||
result = event.node_run_result
|
||||
text = result.outputs.get("text")
|
||||
if isinstance(text, str):
|
||||
self._answer = text
|
||||
self._usage = result.llm_usage
|
||||
|
||||
prompts = result.process_data.get("prompts")
|
||||
if isinstance(prompts, list):
|
||||
self._saved_prompt = cast(list[SavedPrompt], prompts)
|
||||
|
||||
def _publish_message_end(self, outputs: Mapping[str, object]) -> None:
|
||||
result = outputs.get("result")
|
||||
if isinstance(result, str) and not self._answer:
|
||||
self._answer = result
|
||||
|
||||
self._queue_manager.publish(
|
||||
QueueMessageEndEvent(
|
||||
llm_result=LLMResult(
|
||||
model=self._application_generate_entity.model_conf.model,
|
||||
prompt_messages=[],
|
||||
message=AssistantPromptMessage(content=self._answer),
|
||||
usage=self._usage,
|
||||
),
|
||||
saved_prompt=self._saved_prompt,
|
||||
),
|
||||
PublishFrom.APPLICATION_MANAGER,
|
||||
)
|
||||
|
||||
def _publish_error(self, error: Any) -> None:
|
||||
self._queue_manager.publish(
|
||||
QueueErrorEvent(error=ValueError(str(error))),
|
||||
PublishFrom.APPLICATION_MANAGER,
|
||||
)
|
||||
64
api/core/app/apps/completion/runtime_workflow_builder.py
Normal file
64
api/core/app/apps/completion/runtime_workflow_builder.py
Normal file
@ -0,0 +1,64 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from core.app.apps.completion.app_config_manager import CompletionAppConfig
|
||||
from graphon.nodes import BuiltinNodeTypes
|
||||
from models.model import App, AppMode
|
||||
from services.workflow.workflow_converter import WorkflowConverter, WorkflowGraph
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeCompletionWorkflow:
|
||||
workflow_id: str
|
||||
root_node_id: str
|
||||
graph_dict: WorkflowGraph
|
||||
|
||||
|
||||
class RuntimeCompletionWorkflowBuilder:
|
||||
"""Build the transient WorkflowEntry graph used by Completion execution."""
|
||||
|
||||
def __init__(self, workflow_converter: WorkflowConverter | None = None) -> None:
|
||||
self._workflow_converter = workflow_converter or WorkflowConverter()
|
||||
|
||||
def build(self, *, app_model: App, app_config: CompletionAppConfig) -> RuntimeCompletionWorkflow:
|
||||
graph, _ = self._workflow_converter.build_graph_from_app_config(
|
||||
app_model=app_model,
|
||||
app_config=app_config,
|
||||
target_app_mode=AppMode.WORKFLOW,
|
||||
)
|
||||
self._route_external_data_query_to_sys_query(graph)
|
||||
return RuntimeCompletionWorkflow(
|
||||
workflow_id=f"completion-runtime-{uuid4()}",
|
||||
root_node_id="start",
|
||||
graph_dict=graph,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _route_external_data_query_to_sys_query(graph: WorkflowGraph) -> None:
|
||||
"""Preserve Completion API-based variable behavior in the runtime graph."""
|
||||
for node in graph["nodes"]:
|
||||
data = node.get("data", {})
|
||||
if data.get("type") != BuiltinNodeTypes.HTTP_REQUEST:
|
||||
continue
|
||||
|
||||
body = data.get("body")
|
||||
if not isinstance(body, dict) or body.get("type") != "json":
|
||||
continue
|
||||
|
||||
raw_body_data = body.get("data")
|
||||
if not isinstance(raw_body_data, str):
|
||||
continue
|
||||
|
||||
try:
|
||||
body_data: dict[str, Any] = json.loads(raw_body_data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
params = body_data.get("params")
|
||||
if not isinstance(params, dict) or params.get("query") != "":
|
||||
continue
|
||||
|
||||
params["query"] = "{{#sys.query#}}"
|
||||
body["data"] = json.dumps(body_data)
|
||||
250
api/core/app/apps/completion/workflow_runner.py
Normal file
250
api/core/app/apps/completion/workflow_runner.py
Normal file
@ -0,0 +1,250 @@
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.apps.base_app_runner import AppRunner
|
||||
from core.app.apps.completion.app_config_manager import CompletionAppConfig
|
||||
from core.app.apps.completion.graph_event_adapter import CompletionGraphEventAdapter
|
||||
from core.app.apps.completion.runtime_workflow_builder import RuntimeCompletionWorkflowBuilder
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.apps.workflow_app_runner import init_graph
|
||||
from core.app.entities.app_invoke_entities import CompletionAppGenerateEntity, UserFrom
|
||||
from core.entities import DEFAULT_PLUGIN_ID
|
||||
from core.moderation.base import ModerationError
|
||||
from core.workflow.node_runtime import DIFY_BEFORE_LLM_INVOKE_KEY
|
||||
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
|
||||
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
|
||||
from core.workflow.workflow_entry import WorkflowEntry
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_hosting_provider import hosting_configuration
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.graph_engine.command_channels import RedisChannel
|
||||
from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent, PromptMessage
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from models.model import App, Message
|
||||
from models.provider import ProviderType
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModeratedCompletionInputs:
|
||||
stopped: bool
|
||||
inputs: Mapping[str, Any]
|
||||
query: str
|
||||
|
||||
|
||||
class CompletionWorkflowRunner(AppRunner):
|
||||
"""Run Completion through a transient WorkflowEntry graph."""
|
||||
|
||||
_runtime_workflow_builder: RuntimeCompletionWorkflowBuilder
|
||||
|
||||
def __init__(self, runtime_workflow_builder: RuntimeCompletionWorkflowBuilder | None = None) -> None:
|
||||
self._runtime_workflow_builder = runtime_workflow_builder or RuntimeCompletionWorkflowBuilder()
|
||||
|
||||
def run(
|
||||
self,
|
||||
application_generate_entity: CompletionAppGenerateEntity,
|
||||
queue_manager: AppQueueManager,
|
||||
message: Message,
|
||||
) -> None:
|
||||
app_config = cast(CompletionAppConfig, application_generate_entity.app_config)
|
||||
app_record = self._get_app(app_config.app_id)
|
||||
|
||||
moderation_result = self._run_input_moderation(
|
||||
app_record=app_record,
|
||||
application_generate_entity=application_generate_entity,
|
||||
queue_manager=queue_manager,
|
||||
message=message,
|
||||
)
|
||||
if moderation_result.stopped:
|
||||
return
|
||||
|
||||
runtime_workflow = self._runtime_workflow_builder.build(app_model=app_record, app_config=app_config)
|
||||
variable_pool = self._build_variable_pool(
|
||||
application_generate_entity=application_generate_entity,
|
||||
message=message,
|
||||
workflow_id=runtime_workflow.workflow_id,
|
||||
root_node_id=runtime_workflow.root_node_id,
|
||||
inputs=moderation_result.inputs,
|
||||
query=moderation_result.query,
|
||||
)
|
||||
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
|
||||
user_from = self._resolve_user_from(application_generate_entity)
|
||||
extra_context: dict[str, Any] = {}
|
||||
if self._should_check_hosting_moderation(application_generate_entity):
|
||||
extra_context[DIFY_BEFORE_LLM_INVOKE_KEY] = self._build_hosting_moderation_hook(
|
||||
application_generate_entity=application_generate_entity,
|
||||
queue_manager=queue_manager,
|
||||
)
|
||||
|
||||
graph = init_graph(
|
||||
app_id=app_config.app_id,
|
||||
graph_config=runtime_workflow.graph_dict,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
user_from=user_from,
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
workflow_id=runtime_workflow.workflow_id,
|
||||
tenant_id=app_config.tenant_id,
|
||||
user_id=application_generate_entity.user_id,
|
||||
root_node_id=runtime_workflow.root_node_id,
|
||||
trace_session_id=application_generate_entity.extras.get("trace_session_id"),
|
||||
call_depth=application_generate_entity.call_depth,
|
||||
extra_context=extra_context,
|
||||
)
|
||||
|
||||
queue_manager.graph_runtime_state = graph_runtime_state
|
||||
command_channel = RedisChannel(redis_client, f"workflow:{application_generate_entity.task_id}:commands")
|
||||
workflow_entry = WorkflowEntry(
|
||||
tenant_id=app_config.tenant_id,
|
||||
app_id=app_config.app_id,
|
||||
workflow_id=runtime_workflow.workflow_id,
|
||||
graph_config=runtime_workflow.graph_dict,
|
||||
graph=graph,
|
||||
user_id=application_generate_entity.user_id,
|
||||
user_from=user_from,
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
call_depth=application_generate_entity.call_depth,
|
||||
variable_pool=variable_pool,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
command_channel=command_channel,
|
||||
)
|
||||
adapter = CompletionGraphEventAdapter(
|
||||
application_generate_entity=application_generate_entity,
|
||||
queue_manager=queue_manager,
|
||||
)
|
||||
for event in workflow_entry.run():
|
||||
adapter.handle_event(event)
|
||||
|
||||
def _get_app(self, app_id: str) -> App:
|
||||
app_record = db.session.scalar(select(App).where(App.id == app_id))
|
||||
if not app_record:
|
||||
raise ValueError("App not found")
|
||||
return app_record
|
||||
|
||||
def _run_input_moderation(
|
||||
self,
|
||||
*,
|
||||
app_record: App,
|
||||
application_generate_entity: CompletionAppGenerateEntity,
|
||||
queue_manager: AppQueueManager,
|
||||
message: Message,
|
||||
) -> ModeratedCompletionInputs:
|
||||
app_config = cast(CompletionAppConfig, application_generate_entity.app_config)
|
||||
prompt_messages, _ = self.organize_prompt_messages(
|
||||
app_record=app_record,
|
||||
model_config=application_generate_entity.model_conf,
|
||||
prompt_template_entity=app_config.prompt_template,
|
||||
inputs=application_generate_entity.inputs,
|
||||
files=application_generate_entity.files,
|
||||
query=application_generate_entity.query,
|
||||
image_detail_config=self._resolve_image_detail_config(application_generate_entity),
|
||||
)
|
||||
|
||||
try:
|
||||
_, inputs, query = self.moderation_for_inputs(
|
||||
app_id=app_record.id,
|
||||
tenant_id=app_config.tenant_id,
|
||||
app_generate_entity=application_generate_entity,
|
||||
inputs=application_generate_entity.inputs,
|
||||
query=application_generate_entity.query or "",
|
||||
message_id=message.id,
|
||||
)
|
||||
except ModerationError as exc:
|
||||
self.direct_output(
|
||||
queue_manager=queue_manager,
|
||||
app_generate_entity=application_generate_entity,
|
||||
prompt_messages=prompt_messages,
|
||||
text=str(exc),
|
||||
stream=application_generate_entity.stream,
|
||||
)
|
||||
return ModeratedCompletionInputs(
|
||||
stopped=True,
|
||||
inputs=application_generate_entity.inputs,
|
||||
query=application_generate_entity.query or "",
|
||||
)
|
||||
|
||||
return ModeratedCompletionInputs(stopped=False, inputs=inputs, query=query)
|
||||
|
||||
def _build_hosting_moderation_hook(
|
||||
self,
|
||||
*,
|
||||
application_generate_entity: CompletionAppGenerateEntity,
|
||||
queue_manager: AppQueueManager,
|
||||
) -> Callable[[Sequence[PromptMessage]], None]:
|
||||
def check(prompt_messages: Sequence[PromptMessage]) -> None:
|
||||
if self.check_hosting_moderation(
|
||||
application_generate_entity=application_generate_entity,
|
||||
queue_manager=queue_manager,
|
||||
prompt_messages=list(prompt_messages),
|
||||
):
|
||||
raise GenerateTaskStoppedError()
|
||||
|
||||
return check
|
||||
|
||||
def _should_check_hosting_moderation(self, application_generate_entity: CompletionAppGenerateEntity) -> bool:
|
||||
moderation_config = hosting_configuration.moderation_config
|
||||
openai_provider_name = f"{DEFAULT_PLUGIN_ID}/openai/openai"
|
||||
hosting_provider = hosting_configuration.provider_map.get(openai_provider_name)
|
||||
if not (
|
||||
moderation_config
|
||||
and moderation_config.enabled is True
|
||||
and hosting_provider
|
||||
and hosting_provider.enabled is True
|
||||
and hosting_provider.credentials is not None
|
||||
):
|
||||
return False
|
||||
|
||||
model_config = application_generate_entity.model_conf
|
||||
provider_model_bundle = getattr(model_config, "provider_model_bundle", None)
|
||||
configuration = getattr(provider_model_bundle, "configuration", None)
|
||||
using_provider_type = getattr(configuration, "using_provider_type", None)
|
||||
return (
|
||||
using_provider_type == ProviderType.SYSTEM
|
||||
and getattr(model_config, "provider", None) in moderation_config.providers
|
||||
)
|
||||
|
||||
def _build_variable_pool(
|
||||
self,
|
||||
*,
|
||||
application_generate_entity: CompletionAppGenerateEntity,
|
||||
message: Message,
|
||||
workflow_id: str,
|
||||
root_node_id: str,
|
||||
inputs: Mapping[str, Any],
|
||||
query: str,
|
||||
) -> VariablePool:
|
||||
variable_pool = VariablePool()
|
||||
system_inputs = build_system_variables(
|
||||
files=application_generate_entity.files,
|
||||
user_id=application_generate_entity.user_id,
|
||||
app_id=application_generate_entity.app_config.app_id,
|
||||
workflow_id=workflow_id,
|
||||
workflow_execution_id=application_generate_entity.task_id,
|
||||
timestamp=int(time.time()),
|
||||
query=query,
|
||||
conversation_id=getattr(message, "conversation_id", None),
|
||||
)
|
||||
add_variables_to_pool(
|
||||
variable_pool,
|
||||
build_bootstrap_variables(system_variables=system_inputs, environment_variables=[]),
|
||||
)
|
||||
add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=inputs)
|
||||
return variable_pool
|
||||
|
||||
@staticmethod
|
||||
def _resolve_user_from(application_generate_entity: CompletionAppGenerateEntity) -> UserFrom:
|
||||
if application_generate_entity.invoke_from.runs_as_account():
|
||||
return UserFrom.ACCOUNT
|
||||
return UserFrom.END_USER
|
||||
|
||||
@staticmethod
|
||||
def _resolve_image_detail_config(
|
||||
application_generate_entity: CompletionAppGenerateEntity,
|
||||
) -> ImagePromptMessageContent.DETAIL:
|
||||
file_upload_config = application_generate_entity.file_upload_config
|
||||
if file_upload_config and file_upload_config.image_config:
|
||||
return file_upload_config.image_config.detail or ImagePromptMessageContent.DETAIL.LOW
|
||||
return ImagePromptMessageContent.DETAIL.LOW
|
||||
@ -92,6 +92,60 @@ from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_graph(
|
||||
*,
|
||||
app_id: str,
|
||||
graph_config: Mapping[str, Any],
|
||||
graph_runtime_state: GraphRuntimeState,
|
||||
user_from: UserFrom,
|
||||
invoke_from: InvokeFrom,
|
||||
workflow_id: str = "",
|
||||
tenant_id: str = "",
|
||||
user_id: str = "",
|
||||
root_node_id: str | None = None,
|
||||
trace_session_id: str | None = None,
|
||||
call_depth: int = 0,
|
||||
extra_context: Mapping[str, Any] | None = None,
|
||||
) -> Graph:
|
||||
if "nodes" not in graph_config or "edges" not in graph_config:
|
||||
raise ValueError("nodes or edges not found in workflow graph")
|
||||
|
||||
if not isinstance(graph_config.get("nodes"), list):
|
||||
raise ValueError("nodes in workflow graph must be a list")
|
||||
|
||||
if not isinstance(graph_config.get("edges"), list):
|
||||
raise ValueError("edges in workflow graph must be a list")
|
||||
|
||||
run_context = build_dify_run_context(
|
||||
tenant_id=tenant_id or "",
|
||||
app_id=app_id,
|
||||
user_id=user_id,
|
||||
user_from=user_from,
|
||||
invoke_from=invoke_from,
|
||||
trace_session_id=trace_session_id,
|
||||
extra_context=extra_context,
|
||||
)
|
||||
graph_init_context = DifyGraphInitContext(
|
||||
workflow_id=workflow_id,
|
||||
graph_config=graph_config,
|
||||
run_context=run_context,
|
||||
call_depth=call_depth,
|
||||
)
|
||||
node_factory = DifyNodeFactory.from_graph_init_context(
|
||||
graph_init_context=graph_init_context,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
)
|
||||
|
||||
if root_node_id is None:
|
||||
root_node_id = get_default_root_node_id(graph_config)
|
||||
|
||||
graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id=root_node_id)
|
||||
if not graph:
|
||||
raise ValueError("graph not found in workflow")
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
class WorkflowBasedAppRunner:
|
||||
def __init__(
|
||||
self,
|
||||
@ -127,48 +181,18 @@ class WorkflowBasedAppRunner:
|
||||
"""
|
||||
Init graph
|
||||
"""
|
||||
if "nodes" not in graph_config or "edges" not in graph_config:
|
||||
raise ValueError("nodes or edges not found in workflow graph")
|
||||
|
||||
if not isinstance(graph_config.get("nodes"), list):
|
||||
raise ValueError("nodes in workflow graph must be a list")
|
||||
|
||||
if not isinstance(graph_config.get("edges"), list):
|
||||
raise ValueError("edges in workflow graph must be a list")
|
||||
|
||||
# Create explicit graph init context for Graph.init.
|
||||
run_context = build_dify_run_context(
|
||||
tenant_id=tenant_id or "",
|
||||
return init_graph(
|
||||
app_id=self._app_id,
|
||||
user_id=user_id,
|
||||
graph_config=graph_config,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
user_from=user_from,
|
||||
invoke_from=invoke_from,
|
||||
workflow_id=workflow_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
root_node_id=root_node_id,
|
||||
trace_session_id=trace_session_id,
|
||||
)
|
||||
graph_init_context = DifyGraphInitContext(
|
||||
workflow_id=workflow_id,
|
||||
graph_config=graph_config,
|
||||
run_context=run_context,
|
||||
call_depth=0,
|
||||
)
|
||||
|
||||
# Use the provided graph_runtime_state for consistent state management
|
||||
|
||||
node_factory = DifyNodeFactory.from_graph_init_context(
|
||||
graph_init_context=graph_init_context,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
)
|
||||
|
||||
if root_node_id is None:
|
||||
root_node_id = get_default_root_node_id(graph_config)
|
||||
|
||||
# init graph
|
||||
graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id=root_node_id)
|
||||
|
||||
if not graph:
|
||||
raise ValueError("graph not found in workflow")
|
||||
|
||||
return graph
|
||||
|
||||
def _prepare_single_node_execution(
|
||||
self,
|
||||
|
||||
@ -6,6 +6,7 @@ from typing import Any
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from core.app.entities.agent_strategy import AgentStrategyInfo
|
||||
from core.prompt.utils.prompt_message_util import SavedPrompt
|
||||
from core.rag.entities import RetrievalSourceMetadata
|
||||
from core.workflow.nodes.human_input.pause_reason import PauseReason
|
||||
from graphon.entities import WorkflowStartReason
|
||||
@ -273,6 +274,7 @@ class QueueMessageEndEvent(AppQueueEvent):
|
||||
|
||||
event: QueueEvent = QueueEvent.MESSAGE_END
|
||||
llm_result: LLMResult | None = None
|
||||
saved_prompt: list[SavedPrompt] | None = None
|
||||
|
||||
|
||||
class QueueAdvancedChatMessageEndEvent(AppQueueEvent):
|
||||
|
||||
@ -5,6 +5,7 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
||||
|
||||
from core.app.entities.agent_strategy import AgentStrategyInfo
|
||||
from core.prompt.utils.prompt_message_util import SavedPrompt
|
||||
from core.rag.entities import RetrievalSourceMetadata
|
||||
from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig
|
||||
from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType
|
||||
@ -46,6 +47,7 @@ class EasyUITaskState(TaskState):
|
||||
"""
|
||||
|
||||
llm_result: LLMResult
|
||||
saved_prompt: list[SavedPrompt] | None = None
|
||||
|
||||
|
||||
class WorkflowTaskState(TaskState):
|
||||
|
||||
@ -277,6 +277,9 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
|
||||
if isinstance(event, QueueMessageEndEvent):
|
||||
if event.llm_result:
|
||||
self._task_state.llm_result = event.llm_result
|
||||
saved_prompt = getattr(event, "saved_prompt", None)
|
||||
if saved_prompt is not None:
|
||||
self._task_state.saved_prompt = saved_prompt
|
||||
else:
|
||||
self._handle_stop(event)
|
||||
|
||||
@ -393,9 +396,11 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
|
||||
if not conversation:
|
||||
raise ValueError(f"Conversation {self._conversation_id} not found")
|
||||
|
||||
saved_prompt = PromptMessageUtil.prompt_messages_to_prompt_for_saving(
|
||||
self._model_config.mode, self._task_state.llm_result.prompt_messages
|
||||
)
|
||||
saved_prompt = self._task_state.saved_prompt
|
||||
if saved_prompt is None:
|
||||
saved_prompt = PromptMessageUtil.prompt_messages_to_prompt_for_saving(
|
||||
self._model_config.mode, self._task_state.llm_result.prompt_messages
|
||||
)
|
||||
object.__setattr__(message, "message", saved_prompt)
|
||||
message.message_tokens = usage.prompt_tokens
|
||||
message.message_unit_price = usage.prompt_unit_price
|
||||
|
||||
@ -23,6 +23,8 @@ from core.prompt.entities.advanced_prompt_entities import MemoryConfig
|
||||
from core.trigger.constants import TRIGGER_NODE_TYPES
|
||||
from core.workflow.human_input_adapter import adapt_node_config_for_graph
|
||||
from core.workflow.node_runtime import (
|
||||
DIFY_BEFORE_LLM_INVOKE_KEY,
|
||||
BeforeLLMInvoke,
|
||||
DifyFileReferenceFactory,
|
||||
DifyHumanInputNodeRuntime,
|
||||
DifyPreparedLLM,
|
||||
@ -545,11 +547,19 @@ class DifyNodeFactory(NodeFactory):
|
||||
) -> dict[str, object]:
|
||||
validated_node_data = cast(LLMCompatibleNodeData, node_data)
|
||||
model_instance = self._build_model_instance_for_llm_node(validated_node_data)
|
||||
before_llm_invoke = cast(
|
||||
BeforeLLMInvoke | None,
|
||||
self.graph_init_params.run_context.get(DIFY_BEFORE_LLM_INVOKE_KEY),
|
||||
)
|
||||
node_init_kwargs: dict[str, object] = {
|
||||
"credentials_provider": self._llm_credentials_provider,
|
||||
"model_factory": self._llm_model_factory,
|
||||
"model_instance": (
|
||||
self._wrap_model_instance_for_node(node_data=validated_node_data, model_instance=model_instance)
|
||||
self._wrap_model_instance_for_node(
|
||||
node_data=validated_node_data,
|
||||
model_instance=model_instance,
|
||||
before_invoke=before_llm_invoke,
|
||||
)
|
||||
if wrap_model_instance
|
||||
else model_instance
|
||||
),
|
||||
@ -581,13 +591,14 @@ class DifyNodeFactory(NodeFactory):
|
||||
*,
|
||||
node_data: LLMCompatibleNodeData,
|
||||
model_instance: ModelInstance,
|
||||
before_invoke: BeforeLLMInvoke | None = None,
|
||||
) -> DifyPreparedLLM:
|
||||
# Only graphon's LLM node consumes the polling protocol. Keep classifier
|
||||
# and extractor nodes on the existing wrapper even if the same model
|
||||
# advertises polling support.
|
||||
if node_data.type == BuiltinNodeTypes.LLM and DifyNodeFactory._supports_plugin_llm_polling(model_instance):
|
||||
return DifyPreparedPollingLLM(model_instance)
|
||||
return DifyPreparedLLM(model_instance)
|
||||
return DifyPreparedPollingLLM(model_instance, before_invoke=before_invoke)
|
||||
return DifyPreparedLLM(model_instance, before_invoke=before_invoke)
|
||||
|
||||
@staticmethod
|
||||
def _supports_plugin_llm_polling(model_instance: ModelInstance) -> bool:
|
||||
|
||||
@ -94,6 +94,8 @@ if TYPE_CHECKING:
|
||||
from graphon.nodes.tool.entities import ToolNodeData
|
||||
|
||||
|
||||
DIFY_BEFORE_LLM_INVOKE_KEY = "_dify_before_llm_invoke"
|
||||
BeforeLLMInvoke = Callable[[Sequence[PromptMessage]], None]
|
||||
_file_access_controller = DatabaseFileAccessController()
|
||||
|
||||
|
||||
@ -150,8 +152,9 @@ class DifyFileReferenceFactory(FileReferenceFactoryProtocol):
|
||||
class DifyPreparedLLM(LLMProtocol):
|
||||
"""Workflow-layer adapter that hides the full `ModelInstance` API from `graphon` nodes."""
|
||||
|
||||
def __init__(self, model_instance: ModelInstance) -> None:
|
||||
def __init__(self, model_instance: ModelInstance, before_invoke: BeforeLLMInvoke | None = None) -> None:
|
||||
self._model_instance = model_instance
|
||||
self._before_invoke = before_invoke
|
||||
|
||||
@property
|
||||
@override
|
||||
@ -192,6 +195,10 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
|
||||
return self._model_instance.get_llm_num_tokens(prompt_messages)
|
||||
|
||||
def _run_before_invoke(self, prompt_messages: Sequence[PromptMessage]) -> None:
|
||||
if self._before_invoke is not None:
|
||||
self._before_invoke(prompt_messages)
|
||||
|
||||
@overload
|
||||
def invoke_llm(
|
||||
self,
|
||||
@ -224,6 +231,7 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
stop: Sequence[str] | None,
|
||||
stream: bool,
|
||||
) -> LLMResult | Generator[LLMResultChunk, None, None]:
|
||||
self._run_before_invoke(prompt_messages)
|
||||
return self._model_instance.invoke_llm(
|
||||
prompt_messages=list(prompt_messages),
|
||||
model_parameters=dict(model_parameters),
|
||||
@ -264,6 +272,7 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
stop: Sequence[str] | None,
|
||||
stream: bool,
|
||||
) -> LLMResultWithStructuredOutput | Generator[LLMResultChunkWithStructuredOutput, None, None]:
|
||||
self._run_before_invoke(prompt_messages)
|
||||
return invoke_llm_with_structured_output(
|
||||
provider=self.provider,
|
||||
model_schema=self.get_model_schema(),
|
||||
@ -283,10 +292,10 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
class DifyPreparedPollingLLM(DifyPreparedLLM, LLMPollingCapableProtocol):
|
||||
"""Prepared workflow LLM adapter that exposes Graphon's polling protocol."""
|
||||
|
||||
def __init__(self, model_instance: ModelInstance) -> None:
|
||||
def __init__(self, model_instance: ModelInstance, before_invoke: BeforeLLMInvoke | None = None) -> None:
|
||||
from core.plugin.impl.model_runtime import PluginModelRuntime
|
||||
|
||||
super().__init__(model_instance)
|
||||
super().__init__(model_instance, before_invoke=before_invoke)
|
||||
model_type_instance = model_instance.model_type_instance
|
||||
if not isinstance(model_type_instance, LargeLanguageModel):
|
||||
raise TypeError("Polling wrapper requires a large-language-model instance.")
|
||||
@ -307,6 +316,7 @@ class DifyPreparedPollingLLM(DifyPreparedLLM, LLMPollingCapableProtocol):
|
||||
stop: Sequence[str] | None,
|
||||
json_schema: Mapping[str, Any] | None,
|
||||
) -> LLMPollingResult:
|
||||
self._run_before_invoke(prompt_messages)
|
||||
return self._plugin_model_runtime.start_llm_polling(
|
||||
provider=self.provider,
|
||||
model=self.model_name,
|
||||
|
||||
@ -93,7 +93,21 @@ User-scoped operations
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}
|
||||
### [GET] /apps/{app_id}/check-dependencies
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/describe
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -109,21 +123,7 @@ User-scoped operations
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/dependencies:check
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/dsl
|
||||
### [GET] /apps/{app_id}/export
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -140,7 +140,7 @@ User-scoped operations
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/files
|
||||
### [POST] /apps/{app_id}/files/upload
|
||||
Upload a file to use as an input variable when running the app
|
||||
|
||||
#### Parameters
|
||||
@ -160,7 +160,7 @@ Upload a file to use as an input variable when running the app
|
||||
| 415 | Unsupported file type or blocked extension | |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/human-input-forms/{form_token}
|
||||
### [GET] /apps/{app_id}/form/human_input/{form_token}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -174,7 +174,7 @@ Upload a file to use as an input variable when running the app
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Form definition | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/human-input-forms/{form_token}:submit
|
||||
### [POST] /apps/{app_id}/form/human_input/{form_token}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -196,38 +196,7 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/tasks/{task_id}/events
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| continue_on_pause | query | Whether to keep the event stream open on pause | No | boolean |
|
||||
| include_state_snapshot | query | Whether to include workflow state snapshots | No | boolean |
|
||||
| app_id | path | | Yes | string |
|
||||
| task_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/tasks/{task_id}:stop
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| task_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}:run
|
||||
### [POST] /apps/{app_id}/run
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -247,6 +216,37 @@ Upload a file to use as an input variable when running the app
|
||||
| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/tasks/{task_id}/events
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| continue_on_pause | query | Whether to keep the event stream open on pause | No | boolean |
|
||||
| include_state_snapshot | query | Whether to include workflow state snapshots | No | boolean |
|
||||
| app_id | path | | Yes | string |
|
||||
| task_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/tasks/{task_id}/stop
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| task_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /oauth/device/approve
|
||||
#### Request Body
|
||||
|
||||
@ -330,7 +330,7 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /permitted-external-apps/{app_id}
|
||||
### [GET] /permitted-external-apps/{app_id}/describe
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -391,7 +391,7 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /workspaces/{workspace_id}/apps/imports/{import_id}:confirm
|
||||
### [POST] /workspaces/{workspace_id}/apps/imports/{import_id}/confirm
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -460,7 +460,7 @@ Upload a file to use as an input variable when running the app
|
||||
| 200 | Member removed | **application/json**: [MemberActionResponse](#memberactionresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [PATCH] /workspaces/{workspace_id}/members/{member_id}
|
||||
### [PUT] /workspaces/{workspace_id}/members/{member_id}/role
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -482,7 +482,7 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /workspaces/{workspace_id}:switch
|
||||
### [POST] /workspaces/{workspace_id}/switch
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -532,7 +532,7 @@ Upload a file to use as an input variable when running the app
|
||||
|
||||
#### AppDescribeQuery
|
||||
|
||||
`?fields=` allow-list for GET /apps/<id>.
|
||||
`?fields=` allow-list for GET /apps/<id>/describe.
|
||||
|
||||
Empty / omitted → all blocks. Unknown member → ValidationError → 422.
|
||||
|
||||
@ -550,7 +550,7 @@ Empty / omitted → all blocks. Unknown member → ValidationError → 422.
|
||||
|
||||
#### AppDslExportQuery
|
||||
|
||||
Query parameters for GET /apps/<app_id>/dsl.
|
||||
Query parameters for GET /apps/<app_id>/export.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
@ -762,7 +762,7 @@ future server adds a code. Formatter tests pin emitted values to the enum.
|
||||
|
||||
#### FormSubmitResponse
|
||||
|
||||
Empty 200 body for POST /apps/<id>/human-input-forms/<token>:submit. `extra='forbid'`
|
||||
Empty 200 body for POST /apps/<id>/form/human_input/<token>. `extra='forbid'`
|
||||
pins `additionalProperties: false` so the generated contract is an exact `{}` rather
|
||||
than an under-annotated open object.
|
||||
|
||||
@ -1016,7 +1016,7 @@ generated CLI whitelist all derive from it.
|
||||
|
||||
#### TaskStopResponse
|
||||
|
||||
200 body for POST /apps/<id>/tasks/<task_id>:stop. The handler always returns
|
||||
200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns
|
||||
{"result": "success"}, so `result` is required (no default) — the generated contract
|
||||
types it as a required `'success'` rather than an optional field.
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@ class AppTaskService:
|
||||
# Legacy mechanism: Set stop flag in Redis
|
||||
AppQueueManager.set_stop_flag(task_id, invoke_from, user_id)
|
||||
|
||||
# New mechanism: Send stop command via GraphEngine for workflow-based apps
|
||||
# This ensures proper workflow status recording in the persistence layer
|
||||
if app_mode in (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW):
|
||||
# New mechanism: send stop command via GraphEngine for graph-backed apps.
|
||||
# Completion uses WorkflowEntry at runtime but keeps legacy message persistence.
|
||||
if app_mode in (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW, AppMode.COMPLETION):
|
||||
GraphEngineManager(redis_client).send_stop_command(task_id)
|
||||
|
||||
@ -120,7 +120,43 @@ class WorkflowConverter:
|
||||
# convert app model config
|
||||
app_config = self._convert_to_app_config(app_model=app_model, app_model_config=app_model_config)
|
||||
|
||||
# init workflow graph
|
||||
graph, features = self.build_graph_from_app_config(
|
||||
app_model=app_model,
|
||||
app_config=app_config,
|
||||
target_app_mode=new_app_mode,
|
||||
)
|
||||
|
||||
# create workflow record
|
||||
workflow = Workflow(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
type=WorkflowType.from_app_mode(new_app_mode).value,
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(graph),
|
||||
features=json.dumps(features),
|
||||
created_by=account_id,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
)
|
||||
|
||||
db.session.add(workflow)
|
||||
db.session.commit()
|
||||
|
||||
return workflow
|
||||
|
||||
def build_graph_from_app_config(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
app_config: EasyUIBasedAppConfig,
|
||||
target_app_mode: AppMode,
|
||||
) -> tuple[WorkflowGraph, dict[str, Any]]:
|
||||
"""
|
||||
Build a workflow graph from an EasyUI app config without persisting it.
|
||||
|
||||
This is shared by the persisted app-conversion flow and runtime-only
|
||||
execution paths that need a graph but must not create a Workflow row.
|
||||
"""
|
||||
graph: WorkflowGraph = {"nodes": [], "edges": []}
|
||||
|
||||
# Convert list:
|
||||
@ -152,7 +188,7 @@ class WorkflowConverter:
|
||||
# convert to knowledge retrieval node
|
||||
if app_config.dataset:
|
||||
knowledge_retrieval_node = self._convert_to_knowledge_retrieval_node(
|
||||
new_app_mode=new_app_mode, dataset_config=app_config.dataset, model_config=app_config.model
|
||||
new_app_mode=target_app_mode, dataset_config=app_config.dataset, model_config=app_config.model
|
||||
)
|
||||
|
||||
if knowledge_retrieval_node:
|
||||
@ -161,7 +197,7 @@ class WorkflowConverter:
|
||||
# convert to llm node
|
||||
llm_node = self._convert_to_llm_node(
|
||||
original_app_mode=AppMode.value_of(app_model.mode),
|
||||
new_app_mode=new_app_mode,
|
||||
new_app_mode=target_app_mode,
|
||||
graph=graph,
|
||||
model_config=app_config.model,
|
||||
prompt_template=app_config.prompt_template,
|
||||
@ -173,7 +209,7 @@ class WorkflowConverter:
|
||||
|
||||
app_model_config_dict = app_config.app_model_config_dict
|
||||
|
||||
match new_app_mode:
|
||||
match target_app_mode:
|
||||
case AppMode.WORKFLOW:
|
||||
end_node = self._convert_to_end_node()
|
||||
graph = self._append_node(graph, end_node)
|
||||
@ -204,23 +240,7 @@ class WorkflowConverter:
|
||||
"sensitive_word_avoidance": app_model_config_dict.get("sensitive_word_avoidance"),
|
||||
}
|
||||
|
||||
# create workflow record
|
||||
workflow = Workflow(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
type=WorkflowType.from_app_mode(new_app_mode).value,
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(graph),
|
||||
features=json.dumps(features),
|
||||
created_by=account_id,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
)
|
||||
|
||||
db.session.add(workflow)
|
||||
db.session.commit()
|
||||
|
||||
return workflow
|
||||
return graph, features
|
||||
|
||||
def _convert_to_app_config(self, app_model: App, app_model_config: AppModelConfig) -> EasyUIBasedAppConfig:
|
||||
app_mode_enum = AppMode.value_of(app_model.mode)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Integration tests for POST /openapi/v1/apps/<id>:run."""
|
||||
"""Integration tests for POST /openapi/v1/apps/<id>/run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -36,7 +36,7 @@ def test_run_chat_dispatches_to_chat_handler(
|
||||
monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate))
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"inputs": {}, "query": "hi", "response_mode": "blocking", "user": "spoof@x.com"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -85,7 +85,7 @@ def test_run_chat_without_query_returns_422(
|
||||
):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -116,7 +116,7 @@ def test_run_completion_dispatches_to_completion_handler(
|
||||
monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate))
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}:run",
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -131,7 +131,7 @@ def test_run_workflow_with_query_returns_422(
|
||||
app = app_with_mode("workflow")
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}:run",
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
json={"inputs": {}, "query": "hi", "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -154,7 +154,7 @@ def test_run_workflow_no_query_dispatches_to_workflow_handler(
|
||||
monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate))
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}:run",
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -170,7 +170,7 @@ def test_run_unsupported_mode_returns_422(
|
||||
app = app_with_mode("channel")
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}:run",
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -181,7 +181,7 @@ def test_run_unsupported_mode_returns_422(
|
||||
def test_run_without_bearer_returns_401(flask_app: Flask, app_in_workspace):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"inputs": {}, "query": "hi"},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
@ -205,7 +205,7 @@ def test_run_with_insufficient_scope_returns_403(
|
||||
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"inputs": {}, "query": "hi"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -215,7 +215,7 @@ def test_run_with_insufficient_scope_returns_403(
|
||||
def test_run_with_unknown_app_returns_404(flask_app: Flask, account_token):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{uuid.uuid4()}:run",
|
||||
f"/openapi/v1/apps/{uuid.uuid4()}/run",
|
||||
json={"inputs": {}, "query": "hi"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -235,7 +235,7 @@ def test_run_streaming_returns_event_stream(
|
||||
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"inputs": {}, "query": "hi", "response_mode": "streaming"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -247,7 +247,7 @@ def test_run_streaming_returns_event_stream(
|
||||
def test_run_without_inputs_returns_422(flask_app: Flask, account_token, app_in_workspace):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"query": "hi"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
|
||||
@ -37,7 +37,7 @@ def test_apps_describe_returns_merged_shape(
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
@ -53,7 +53,7 @@ def test_apps_describe_full_includes_input_schema(
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
@ -70,7 +70,7 @@ def test_apps_describe_fields_info_only(
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}?fields=info",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
@ -86,7 +86,7 @@ def test_apps_describe_fields_parameters_only(
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}?fields=parameters",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=parameters",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
@ -102,7 +102,7 @@ def test_apps_describe_fields_input_schema_only(
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}?fields=input_schema",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=input_schema",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
@ -118,7 +118,7 @@ def test_apps_describe_fields_combined(
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}?fields=info,input_schema",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info,input_schema",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
@ -134,7 +134,7 @@ def test_apps_describe_fields_unknown_returns_422(
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}?fields=garbage",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=garbage",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 422
|
||||
@ -146,7 +146,7 @@ def test_apps_describe_fields_extra_param_returns_422(
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}?fields=info&page=1",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info&page=1",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
@ -167,7 +167,7 @@ class TestDslImportConfirm:
|
||||
|
||||
api = AppDslImportConfirmApi()
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{tenant.id}/apps/imports/{import_id}:confirm", method="POST"
|
||||
f"/openapi/v1/workspaces/{tenant.id}/apps/imports/{import_id}/confirm", method="POST"
|
||||
):
|
||||
result, code = unwrap(api.post)(
|
||||
api, workspace_id=tenant.id, import_id=import_id, auth_data=auth_for(account)
|
||||
@ -198,7 +198,7 @@ class TestDslExport:
|
||||
db_session_with_containers.commit()
|
||||
|
||||
api = AppDslExportApi()
|
||||
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dsl"):
|
||||
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/export"):
|
||||
response, code = unwrap(api.get)(
|
||||
api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery()
|
||||
)
|
||||
@ -216,7 +216,7 @@ class TestDslExport:
|
||||
app_model, account = _app_and_account(db_session_with_containers, mode="workflow")
|
||||
|
||||
api = AppDslExportApi()
|
||||
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dsl"):
|
||||
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/export"):
|
||||
result, code = unwrap(api.get)(
|
||||
api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery()
|
||||
)
|
||||
@ -232,7 +232,7 @@ class TestDslCheckDependencies:
|
||||
app_model, account = _app_and_account(db_session_with_containers, mode="chat")
|
||||
|
||||
api = AppDslCheckDependenciesApi()
|
||||
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dependencies:check"):
|
||||
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/check-dependencies"):
|
||||
result, code = unwrap(api.get)(api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model))
|
||||
|
||||
assert code == 200
|
||||
|
||||
@ -38,7 +38,7 @@ class TestAppRunTaskStop:
|
||||
task_id = str(uuid4())
|
||||
|
||||
api = AppRunTaskStopApi()
|
||||
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/tasks/{task_id}:stop", method="POST"):
|
||||
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/tasks/{task_id}/stop", method="POST"):
|
||||
result = unwrap(api.post)(
|
||||
api,
|
||||
app_id=app_model.id,
|
||||
|
||||
@ -120,7 +120,7 @@ class TestAppDescribe:
|
||||
app_model = _create_app(db_session_with_containers, account, name="Describe Me", enable_api=True)
|
||||
|
||||
api = AppDescribeApi()
|
||||
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}?fields=info"):
|
||||
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/describe?fields=info"):
|
||||
result = unwrap(api.get)(
|
||||
api, app_id=app_model.id, auth_data=auth_for(account), query=AppDescribeQuery(fields="info")
|
||||
)
|
||||
@ -138,7 +138,7 @@ class TestAppDescribe:
|
||||
missing_id = str(uuid4())
|
||||
|
||||
api = AppDescribeApi()
|
||||
with app.test_request_context(f"/openapi/v1/apps/{missing_id}"):
|
||||
with app.test_request_context(f"/openapi/v1/apps/{missing_id}/describe"):
|
||||
with pytest.raises(NotFound):
|
||||
unwrap(api.get)(api, app_id=missing_id, auth_data=auth_for(account), query=AppDescribeQuery())
|
||||
|
||||
@ -151,6 +151,6 @@ class TestAppDescribe:
|
||||
hidden = _create_app(db_session_with_containers, account, name="Hidden", enable_api=False)
|
||||
|
||||
api = AppDescribeApi()
|
||||
with app.test_request_context(f"/openapi/v1/apps/{hidden.id}"):
|
||||
with app.test_request_context(f"/openapi/v1/apps/{hidden.id}/describe"):
|
||||
with pytest.raises(NotFound):
|
||||
unwrap(api.get)(api, app_id=hidden.id, auth_data=auth_for(account), query=AppDescribeQuery())
|
||||
|
||||
@ -43,7 +43,7 @@ class TestAppFileUpload:
|
||||
api = AppFileUploadApi()
|
||||
data = {"file": (BytesIO(content), "note.txt", "text/plain")}
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/apps/{app_model.id}/files",
|
||||
f"/openapi/v1/apps/{app_model.id}/files/upload",
|
||||
method="POST",
|
||||
data=data,
|
||||
content_type="multipart/form-data",
|
||||
|
||||
@ -95,7 +95,7 @@ class TestWorkspaceSwitch:
|
||||
)
|
||||
|
||||
api = WorkspaceSwitchApi()
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{target.id}:switch", method="POST"):
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{target.id}/switch", method="POST"):
|
||||
detail = unwrap(api.post)(api, workspace_id=target.id, auth_data=auth_for(account))
|
||||
|
||||
# Response reflects the post-switch state.
|
||||
@ -118,6 +118,6 @@ class TestWorkspaceSwitch:
|
||||
assert outsider_ws is not None
|
||||
|
||||
api = WorkspaceSwitchApi()
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{outsider_ws.id}:switch", method="POST"):
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{outsider_ws.id}/switch", method="POST"):
|
||||
with pytest.raises(NotFound):
|
||||
unwrap(api.post)(api, workspace_id=outsider_ws.id, auth_data=auth_for(account))
|
||||
|
||||
@ -131,6 +131,34 @@ def test_console_text_api_builds_message_ref(app: Flask, monkeypatch: pytest.Mon
|
||||
assert calls["message_ref"] == MessageRef("tenant-1", "app-1", "message-1", account_id="account-1")
|
||||
|
||||
|
||||
def test_console_text_api_accepts_message_id_without_text(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = {}
|
||||
monkeypatch.setattr(AudioService, "transcript_tts", lambda **kwargs: calls.update(kwargs) or {"audio": "ok"})
|
||||
|
||||
api = ChatMessageTextApi()
|
||||
handler = unwrap(api.post)
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/console/api/apps/app/text-to-audio",
|
||||
method="POST",
|
||||
json={"message_id": "0f67f8c5-8f7c-4ebd-b549-7ac8e972d37e", "streaming": True},
|
||||
),
|
||||
patch("controllers.console.app.audio.current_user", SimpleNamespace(id="account-1")),
|
||||
):
|
||||
response = handler(api, app_model=app_model)
|
||||
|
||||
assert response == {"audio": "ok"}
|
||||
assert calls["text"] == ""
|
||||
assert calls["message_ref"] == MessageRef(
|
||||
"tenant-1",
|
||||
"app-1",
|
||||
"0f67f8c5-8f7c-4ebd-b549-7ac8e972d37e",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
|
||||
def test_console_text_api_error_mapping(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(AudioService, "transcript_tts", lambda **_kwargs: (_ for _ in ()).throw(QuotaExceededError()))
|
||||
|
||||
|
||||
@ -72,7 +72,7 @@ def test_run_chat_always_calls_generate_with_streaming_true(
|
||||
"AppGenerateService",
|
||||
GenerateService,
|
||||
)
|
||||
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}:run", method="POST"):
|
||||
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/run", method="POST"):
|
||||
_run_chat(
|
||||
_make_app(),
|
||||
_make_account(),
|
||||
@ -84,9 +84,9 @@ def test_run_chat_always_calls_generate_with_streaming_true(
|
||||
|
||||
|
||||
def test_stop_task_endpoint_registered(openapi_app):
|
||||
"""POST /openapi/v1/apps/<id>/tasks/<task_id>:stop must be registered."""
|
||||
"""POST /openapi/v1/apps/<id>/tasks/<task_id>/stop must be registered."""
|
||||
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
|
||||
assert "/openapi/v1/apps/<string:app_id>/tasks/<string:task_id>:stop" in rules
|
||||
assert "/openapi/v1/apps/<string:app_id>/tasks/<string:task_id>/stop" in rules
|
||||
|
||||
|
||||
def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch):
|
||||
@ -117,7 +117,7 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipel
|
||||
)
|
||||
|
||||
api = AppRunTaskStopApi()
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1:stop", method="POST"):
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1/stop", method="POST"):
|
||||
result = api.post.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
|
||||
@ -62,7 +62,7 @@ class TestOpenApiHumanInputFormGet:
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
caller = SimpleNamespace(id="acct-1")
|
||||
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"):
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
|
||||
resp = api.get.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
@ -89,7 +89,7 @@ class TestOpenApiHumanInputFormGet:
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
caller = SimpleNamespace(id="acct-1")
|
||||
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/bad"):
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/bad"):
|
||||
with pytest.raises(HumanInputFormNotFound):
|
||||
api.get.__wrapped__(
|
||||
api,
|
||||
@ -117,7 +117,7 @@ class TestOpenApiHumanInputFormGet:
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
caller = SimpleNamespace(id="acct-1")
|
||||
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"):
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
|
||||
with pytest.raises(HumanInputFormNotFound):
|
||||
api.get.__wrapped__(
|
||||
api,
|
||||
@ -145,7 +145,7 @@ class TestOpenApiHumanInputFormGet:
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
caller = SimpleNamespace(id="acct-1")
|
||||
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"):
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
|
||||
with pytest.raises(RecipientSurfaceMismatch):
|
||||
api.get.__wrapped__(
|
||||
api,
|
||||
@ -165,7 +165,7 @@ class TestOpenApiHumanInputFormPost:
|
||||
)
|
||||
|
||||
def test_post_account_caller_uses_user_id(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch):
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
|
||||
|
||||
form = self._make_form()
|
||||
service_mock = Mock()
|
||||
@ -175,12 +175,12 @@ class TestOpenApiHumanInputFormPost:
|
||||
monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock)
|
||||
monkeypatch.setattr(module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
api = OpenApiWorkflowHumanInputFormSubmitApi()
|
||||
api = OpenApiWorkflowHumanInputFormApi()
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
caller = SimpleNamespace(id="acct-42")
|
||||
|
||||
with app.test_request_context(
|
||||
"/openapi/v1/apps/app-1/human-input-forms/tok-1:submit",
|
||||
"/openapi/v1/apps/app-1/form/human_input/tok-1",
|
||||
method="POST",
|
||||
json={"action": "approve", "inputs": {"field1": "val"}},
|
||||
):
|
||||
@ -202,7 +202,7 @@ class TestOpenApiHumanInputFormPost:
|
||||
assert result == ({}, 200)
|
||||
|
||||
def test_post_end_user_caller_uses_end_user_id(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch):
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
|
||||
|
||||
form = self._make_form()
|
||||
service_mock = Mock()
|
||||
@ -212,12 +212,12 @@ class TestOpenApiHumanInputFormPost:
|
||||
monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock)
|
||||
monkeypatch.setattr(module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
api = OpenApiWorkflowHumanInputFormSubmitApi()
|
||||
api = OpenApiWorkflowHumanInputFormApi()
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
caller = SimpleNamespace(id="eu-7")
|
||||
|
||||
with app.test_request_context(
|
||||
"/openapi/v1/apps/app-1/human-input-forms/tok-1:submit",
|
||||
"/openapi/v1/apps/app-1/form/human_input/tok-1",
|
||||
method="POST",
|
||||
json={"action": "approve", "inputs": {}},
|
||||
):
|
||||
@ -241,7 +241,7 @@ class TestOpenApiHumanInputFormPost:
|
||||
def test_post_standalone_web_app_recipient_submits(
|
||||
self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
|
||||
|
||||
form = self._make_form(recipient_type=RecipientType.STANDALONE_WEB_APP)
|
||||
service_mock = Mock()
|
||||
@ -251,12 +251,12 @@ class TestOpenApiHumanInputFormPost:
|
||||
monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock)
|
||||
monkeypatch.setattr(module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
api = OpenApiWorkflowHumanInputFormSubmitApi()
|
||||
api = OpenApiWorkflowHumanInputFormApi()
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
caller = SimpleNamespace(id="anyone")
|
||||
|
||||
with app.test_request_context(
|
||||
"/openapi/v1/apps/app-1/human-input-forms/tok-1:submit",
|
||||
"/openapi/v1/apps/app-1/form/human_input/tok-1",
|
||||
method="POST",
|
||||
json={"action": "approve", "inputs": {}},
|
||||
):
|
||||
@ -272,14 +272,14 @@ class TestOpenApiHumanInputFormPost:
|
||||
|
||||
def test_post_rejects_invalid_body_with_422(self, app: Flask, bypass_pipeline):
|
||||
"""Malformed body → 422 via @accepts (was an unmapped pydantic error → 500)."""
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
|
||||
|
||||
api = OpenApiWorkflowHumanInputFormSubmitApi()
|
||||
api = OpenApiWorkflowHumanInputFormApi()
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
caller = SimpleNamespace(id="acct-42")
|
||||
|
||||
with app.test_request_context(
|
||||
"/openapi/v1/apps/app-1/human-input-forms/tok-1:submit",
|
||||
"/openapi/v1/apps/app-1/form/human_input/tok-1",
|
||||
method="POST",
|
||||
json={"inputs": {"field1": "val"}}, # missing required "action"
|
||||
):
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
"""Tests for the difyctl version gate on /openapi/v1 (HTTP 426 Upgrade Required).
|
||||
|
||||
The gate is an app-level ``before_app_request`` hook: it must fire before routing,
|
||||
so requests to *removed* paths (which no longer match a route) become 426 rather
|
||||
than a bare 404. It reads the difyctl version from the User-Agent and fails open
|
||||
for anything it can't confidently identify as an outdated difyctl.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
# 0.1.0 < MIN_DIFYCTL_VERSION (0.2.0-alpha); 0.2.0-alpha is exactly the floor (allowed).
|
||||
OLD_UA = "difyctl/0.1.0 (darwin; arm64; stable)"
|
||||
CURRENT_UA = "difyctl/0.2.0-alpha (darwin; arm64; stable)"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(openapi_app: Flask):
|
||||
return openapi_app.test_client()
|
||||
|
||||
|
||||
def _gated_path() -> str:
|
||||
"""An existing, auth-guarded route on the surface (GET /apps/<id>)."""
|
||||
return f"/openapi/v1/apps/{uuid.uuid4()}"
|
||||
|
||||
|
||||
class TestVersionGate:
|
||||
def test_old_client_gets_426_with_upgrade_body(self, client):
|
||||
res = client.get(_gated_path(), headers={"User-Agent": OLD_UA})
|
||||
|
||||
assert res.status_code == 426
|
||||
body = res.get_json()
|
||||
assert body["code"] == "upgrade_required"
|
||||
assert body["status"] == 426
|
||||
assert "0.1.0" in body["message"]
|
||||
assert "0.2.0" in body["message"]
|
||||
assert "docs.dify.ai" in body["hint"]
|
||||
|
||||
def test_removed_old_path_gets_426_not_404(self, client):
|
||||
# /apps/<id>/run was renamed to /apps/<id>:run — the old path matches no
|
||||
# route. The app-level gate must still turn it into 426, not a bare 404.
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{uuid.uuid4()}/run",
|
||||
headers={"User-Agent": OLD_UA},
|
||||
json={"inputs": {}},
|
||||
)
|
||||
|
||||
assert res.status_code == 426
|
||||
assert res.get_json()["code"] == "upgrade_required"
|
||||
|
||||
def test_current_client_passes_gate(self, client):
|
||||
# Gate passes → normal dispatch (auth rejects, never the gate's 426).
|
||||
res = client.get(_gated_path(), headers={"User-Agent": CURRENT_UA})
|
||||
|
||||
assert res.status_code != 426
|
||||
|
||||
def test_non_difyctl_ua_passes(self, client):
|
||||
res = client.get(_gated_path(), headers={"User-Agent": "curl/8.4.0"})
|
||||
|
||||
assert res.status_code != 426
|
||||
|
||||
def test_missing_ua_passes(self, client):
|
||||
res = client.get(_gated_path())
|
||||
|
||||
assert res.status_code != 426
|
||||
|
||||
def test_unparseable_version_passes(self, client):
|
||||
res = client.get(_gated_path(), headers={"User-Agent": "difyctl/notaversion (x; y; z)"})
|
||||
|
||||
assert res.status_code != 426
|
||||
|
||||
def test_version_probe_allowlisted(self, client):
|
||||
res = client.get("/openapi/v1/_version", headers={"User-Agent": OLD_UA})
|
||||
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_health_allowlisted(self, client):
|
||||
res = client.get("/openapi/v1/_health", headers={"User-Agent": OLD_UA})
|
||||
|
||||
assert res.status_code == 200
|
||||
@ -1,7 +1,7 @@
|
||||
"""Member endpoints under /openapi/v1/workspaces/<id>/...
|
||||
|
||||
Coverage:
|
||||
- Route registration (5 endpoints across 3 URL patterns)
|
||||
- Route registration (5 endpoints across 4 URL patterns)
|
||||
- Body validation lands at 400 (per spec — not Pydantic's default 422)
|
||||
- Domain exception → HTTP code mapping is preserved with the service's
|
||||
original message (so CLI users see what the console user sees)
|
||||
@ -37,6 +37,7 @@ from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePay
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from controllers.openapi.workspaces import (
|
||||
WorkspaceMemberApi,
|
||||
WorkspaceMemberRoleApi,
|
||||
WorkspaceMembersApi,
|
||||
WorkspaceSwitchApi,
|
||||
)
|
||||
@ -174,7 +175,7 @@ def _account_service(**overrides) -> SimpleNamespace:
|
||||
|
||||
|
||||
def test_switch_route_registered(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>:switch")
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/switch")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceSwitchApi
|
||||
assert "POST" in rule.methods
|
||||
|
||||
@ -190,7 +191,12 @@ def test_member_by_id_route_registered(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members/<string:member_id>")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberApi
|
||||
assert "DELETE" in rule.methods
|
||||
assert "PATCH" in rule.methods
|
||||
|
||||
|
||||
def test_member_role_route_registered(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members/<string:member_id>/role")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberRoleApi
|
||||
assert "PUT" in rule.methods
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -244,17 +250,17 @@ def test_update_role_rejects_invalid_body_with_422(app: Flask, bypass_pipeline):
|
||||
"""Invalid role-update body surfaces as 422 through @accepts (was 400)."""
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberApi()
|
||||
api = WorkspaceMemberRoleApi()
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
|
||||
method="PATCH",
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
|
||||
method="PUT",
|
||||
data=json.dumps({"role": "owner"}), # closed enum rejects owner
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(UnprocessableEntity):
|
||||
api.patch.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id))
|
||||
api.put.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -285,7 +291,7 @@ def test_switch_returns_workspace_detail_with_current_true(
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}:switch", method="POST"):
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
@ -314,7 +320,7 @@ def test_switch_404s_when_service_raises_account_not_link_tenant(
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}:switch", method="POST"):
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(NotFound):
|
||||
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
@ -761,7 +767,7 @@ def test_delete_member_404_when_member_missing(app: Flask, bypass_pipeline, monk
|
||||
def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch):
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberApi()
|
||||
api = WorkspaceMemberRoleApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [
|
||||
@ -779,15 +785,13 @@ def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
|
||||
method="PATCH",
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
|
||||
method="PUT",
|
||||
data=json.dumps({"role": "admin"}),
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.patch.__wrapped__(
|
||||
api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id)
|
||||
)
|
||||
body, status = api.put.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
assert status == 200
|
||||
assert body == {"result": "success"}
|
||||
@ -807,7 +811,7 @@ def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest
|
||||
def test_update_role_exception_mapping(app: Flask, bypass_pipeline, monkeypatch, exc, expected):
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberApi()
|
||||
api = WorkspaceMemberRoleApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [
|
||||
@ -824,14 +828,14 @@ def test_update_role_exception_mapping(app: Flask, bypass_pipeline, monkeypatch,
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
|
||||
method="PATCH",
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
|
||||
method="PUT",
|
||||
data=json.dumps({"role": "admin"}),
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(expected):
|
||||
api.patch.__wrapped__(
|
||||
api.put.__wrapped__(
|
||||
api,
|
||||
workspace_id=ws_id,
|
||||
member_id=member_id,
|
||||
|
||||
@ -1,21 +1,11 @@
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
import core.app.apps.completion.app_runner as module
|
||||
from core.app.apps.completion.app_runner import CompletionAppRunner
|
||||
from core.app.apps.completion.workflow_runner import CompletionWorkflowRunner
|
||||
from core.moderation.base import ModerationError
|
||||
from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CompletionAppRunner()
|
||||
|
||||
|
||||
def _build_app_config(dataset=None, external_tools=None, additional_features=None):
|
||||
app_config = MagicMock()
|
||||
app_config.app_id = "app1"
|
||||
@ -24,7 +14,7 @@ def _build_app_config(dataset=None, external_tools=None, additional_features=Non
|
||||
app_config.dataset = dataset
|
||||
app_config.external_data_variables = external_tools or []
|
||||
app_config.additional_features = additional_features
|
||||
app_config.app_model_config_dict = {"file_upload": {"enabled": True}}
|
||||
app_config.app_model_config_dict = {"file_upload": {"image": {"enabled": True}}}
|
||||
return app_config
|
||||
|
||||
|
||||
@ -38,161 +28,40 @@ def _build_generate_entity(app_config, file_upload_config=None):
|
||||
return SimpleNamespace(
|
||||
app_config=app_config,
|
||||
model_conf=model_conf,
|
||||
inputs={"qvar": "query_from_input"},
|
||||
inputs={"qvar": "original_query_from_input"},
|
||||
query="original_query",
|
||||
files=[],
|
||||
file_upload_config=file_upload_config,
|
||||
stream=True,
|
||||
user_id="user",
|
||||
invoke_from=MagicMock(),
|
||||
trace_manager=None,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def patched_create_session(*, return_value=None):
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = return_value
|
||||
session_context = MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
with patch.object(module, "create_session", return_value=session_context):
|
||||
yield session
|
||||
def test_workflow_runner_direct_outputs_on_input_moderation() -> None:
|
||||
runner = CompletionWorkflowRunner(runtime_workflow_builder=MagicMock())
|
||||
app_record = MagicMock(id="app1", tenant_id="tenant")
|
||||
app_generate_entity = _build_generate_entity(_build_app_config())
|
||||
queue_manager = MagicMock()
|
||||
message = MagicMock(id="msg")
|
||||
runner.organize_prompt_messages = MagicMock(return_value=([], None))
|
||||
runner.moderation_for_inputs = MagicMock(side_effect=ModerationError("blocked"))
|
||||
runner.direct_output = MagicMock()
|
||||
|
||||
result = runner._run_input_moderation(
|
||||
app_record=app_record,
|
||||
application_generate_entity=app_generate_entity,
|
||||
queue_manager=queue_manager,
|
||||
message=message,
|
||||
)
|
||||
|
||||
assert result.stopped is True
|
||||
runner.direct_output.assert_called_once()
|
||||
|
||||
|
||||
class TestCompletionAppRunner:
|
||||
def test_run_app_not_found(self, runner, mocker: MockerFixture):
|
||||
app_config = _build_app_config()
|
||||
app_generate_entity = _build_generate_entity(app_config)
|
||||
def test_workflow_runner_uses_low_image_detail_default() -> None:
|
||||
runner = CompletionWorkflowRunner(runtime_workflow_builder=MagicMock())
|
||||
app_generate_entity = _build_generate_entity(_build_app_config(), file_upload_config=None)
|
||||
|
||||
with patched_create_session(return_value=None):
|
||||
with pytest.raises(ValueError):
|
||||
runner.run(MagicMock(), app_generate_entity, MagicMock(), MagicMock())
|
||||
|
||||
def test_run_moderation_error_outputs_direct(self, runner, mocker: MockerFixture):
|
||||
app_record = MagicMock(id="app1", tenant_id="tenant")
|
||||
|
||||
app_config = _build_app_config()
|
||||
app_generate_entity = _build_generate_entity(app_config)
|
||||
|
||||
runner.organize_prompt_messages = MagicMock(return_value=([], None))
|
||||
runner.moderation_for_inputs = MagicMock(side_effect=ModerationError("blocked"))
|
||||
runner.direct_output = MagicMock()
|
||||
runner._handle_invoke_result = MagicMock()
|
||||
|
||||
with patched_create_session(return_value=app_record):
|
||||
runner.run(MagicMock(), app_generate_entity, MagicMock(), MagicMock(id="msg"))
|
||||
|
||||
runner.direct_output.assert_called_once()
|
||||
runner._handle_invoke_result.assert_not_called()
|
||||
|
||||
def test_run_hosting_moderation_stops(self, runner, mocker: MockerFixture):
|
||||
app_record = MagicMock(id="app1", tenant_id="tenant")
|
||||
|
||||
app_config = _build_app_config()
|
||||
app_generate_entity = _build_generate_entity(app_config)
|
||||
|
||||
runner.organize_prompt_messages = MagicMock(return_value=([], None))
|
||||
runner.moderation_for_inputs = MagicMock(return_value=(None, app_generate_entity.inputs, "query"))
|
||||
runner.check_hosting_moderation = MagicMock(return_value=True)
|
||||
runner._handle_invoke_result = MagicMock()
|
||||
|
||||
with patched_create_session(return_value=app_record):
|
||||
runner.run(MagicMock(), app_generate_entity, MagicMock(), MagicMock(id="msg"))
|
||||
|
||||
runner._handle_invoke_result.assert_not_called()
|
||||
|
||||
def test_run_dataset_and_external_tools_flow(self, runner, mocker: MockerFixture):
|
||||
app_record = MagicMock(id="app1", tenant_id="tenant")
|
||||
|
||||
retrieve_config = MagicMock(query_variable="qvar")
|
||||
dataset_config = MagicMock(dataset_ids=["ds"], retrieve_config=retrieve_config)
|
||||
additional_features = MagicMock(show_retrieve_source=True)
|
||||
app_config = _build_app_config(
|
||||
dataset=dataset_config,
|
||||
external_tools=["tool"],
|
||||
additional_features=additional_features,
|
||||
)
|
||||
|
||||
file_upload_config = MagicMock()
|
||||
file_upload_config.image_config.detail = ImagePromptMessageContent.DETAIL.HIGH
|
||||
|
||||
app_generate_entity = _build_generate_entity(app_config, file_upload_config=file_upload_config)
|
||||
|
||||
runner.organize_prompt_messages = MagicMock(side_effect=[(["pm1"], ["stop"]), (["pm2"], ["stop"])])
|
||||
runner.moderation_for_inputs = MagicMock(return_value=(None, app_generate_entity.inputs, "query"))
|
||||
runner.fill_in_inputs_from_external_data_tools = MagicMock(return_value=app_generate_entity.inputs)
|
||||
runner.check_hosting_moderation = MagicMock(return_value=False)
|
||||
runner.recalc_llm_max_tokens = MagicMock()
|
||||
runner._handle_invoke_result = MagicMock()
|
||||
|
||||
dataset_retrieval = MagicMock()
|
||||
dataset_retrieval.retrieve.return_value = ("ctx", ["file1"])
|
||||
mocker.patch.object(module, "DatasetRetrieval", return_value=dataset_retrieval)
|
||||
|
||||
model_instance = MagicMock()
|
||||
model_instance.invoke_llm.return_value = "invoke_result"
|
||||
mocker.patch.object(module, "ModelInstance", return_value=model_instance)
|
||||
|
||||
with patched_create_session(return_value=app_record):
|
||||
runner.run(MagicMock(), app_generate_entity, MagicMock(), MagicMock(id="msg", tenant_id="tenant"))
|
||||
|
||||
dataset_retrieval.retrieve.assert_called_once()
|
||||
assert dataset_retrieval.retrieve.call_args.kwargs["query"] == "query_from_input"
|
||||
runner._handle_invoke_result.assert_called_once()
|
||||
|
||||
def test_run_closes_scoped_session_before_stream_consumption(self, runner, mocker: MockerFixture):
|
||||
app_record = MagicMock(id="app1", tenant_id="tenant")
|
||||
app_config = _build_app_config()
|
||||
app_generate_entity = _build_generate_entity(app_config)
|
||||
queue_manager = MagicMock()
|
||||
|
||||
events = []
|
||||
runner.organize_prompt_messages = MagicMock(return_value=([], None))
|
||||
runner.moderation_for_inputs = MagicMock(return_value=(None, app_generate_entity.inputs, "query"))
|
||||
runner.check_hosting_moderation = MagicMock(return_value=False)
|
||||
runner.recalc_llm_max_tokens = MagicMock()
|
||||
runner._handle_invoke_result = MagicMock(side_effect=lambda invoke_result, **kwargs: list(invoke_result))
|
||||
|
||||
model_instance = MagicMock()
|
||||
|
||||
def invoke_stream():
|
||||
events.append("first-chunk")
|
||||
yield "chunk"
|
||||
|
||||
def invoke_llm(**kwargs):
|
||||
events.append("invoke")
|
||||
return invoke_stream()
|
||||
|
||||
model_instance.invoke_llm.side_effect = invoke_llm
|
||||
mocker.patch.object(module, "ModelInstance", return_value=model_instance)
|
||||
mocker.patch.object(module.db.session, "close", side_effect=lambda: events.append("close"))
|
||||
|
||||
with patched_create_session(return_value=app_record):
|
||||
runner.run(MagicMock(), app_generate_entity, queue_manager, MagicMock(id="msg"))
|
||||
|
||||
assert events == ["close", "invoke", "first-chunk"]
|
||||
runner._handle_invoke_result.assert_called_once_with(
|
||||
invoke_result=ANY,
|
||||
queue_manager=queue_manager,
|
||||
stream=True,
|
||||
message_id="msg",
|
||||
user_id="user",
|
||||
tenant_id="tenant",
|
||||
)
|
||||
|
||||
def test_run_uses_low_image_detail_default(self, runner, mocker: MockerFixture):
|
||||
app_record = MagicMock(id="app1", tenant_id="tenant")
|
||||
|
||||
app_config = _build_app_config()
|
||||
app_generate_entity = _build_generate_entity(app_config, file_upload_config=None)
|
||||
|
||||
runner.organize_prompt_messages = MagicMock(return_value=([], None))
|
||||
runner.moderation_for_inputs = MagicMock(return_value=(None, app_generate_entity.inputs, "query"))
|
||||
runner.check_hosting_moderation = MagicMock(return_value=True)
|
||||
|
||||
with patched_create_session(return_value=app_record):
|
||||
runner.run(MagicMock(), app_generate_entity, MagicMock(), MagicMock(id="msg"))
|
||||
|
||||
assert (
|
||||
runner.organize_prompt_messages.call_args.kwargs["image_detail_config"]
|
||||
== ImagePromptMessageContent.DETAIL.LOW
|
||||
)
|
||||
assert runner._resolve_image_detail_config(app_generate_entity) == ImagePromptMessageContent.DETAIL.LOW
|
||||
|
||||
@ -323,7 +323,7 @@ class TestCompletionAppGenerator:
|
||||
|
||||
runner_instance = MagicMock()
|
||||
runner_instance.run.side_effect = error
|
||||
mocker.patch.object(module, "CompletionAppRunner", return_value=runner_instance)
|
||||
mocker.patch.object(module, "CompletionWorkflowRunner", return_value=runner_instance)
|
||||
|
||||
queue_manager = MagicMock()
|
||||
generator._generate_worker(
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.app.apps.completion.runtime_workflow_builder import RuntimeCompletionWorkflowBuilder
|
||||
from graphon.nodes import BuiltinNodeTypes
|
||||
from models.model import AppMode
|
||||
|
||||
|
||||
def test_builder_returns_runtime_graph_without_workflow_record() -> None:
|
||||
app_model = SimpleNamespace(mode=AppMode.COMPLETION)
|
||||
app_config = MagicMock()
|
||||
workflow_converter = MagicMock(
|
||||
build_graph_from_app_config=MagicMock(return_value=({"nodes": [{"id": "start"}], "edges": []}, {}))
|
||||
)
|
||||
|
||||
result = RuntimeCompletionWorkflowBuilder(workflow_converter=workflow_converter).build(
|
||||
app_model=app_model,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
assert result.workflow_id.startswith("completion-runtime-")
|
||||
assert result.root_node_id == "start"
|
||||
assert result.graph_dict == {"nodes": [{"id": "start"}], "edges": []}
|
||||
workflow_converter.build_graph_from_app_config.assert_called_once_with(
|
||||
app_model=app_model,
|
||||
app_config=app_config,
|
||||
target_app_mode=AppMode.WORKFLOW,
|
||||
)
|
||||
|
||||
|
||||
def test_builder_routes_api_based_variable_query_to_runtime_sys_query() -> None:
|
||||
app_model = SimpleNamespace(mode=AppMode.COMPLETION)
|
||||
app_config = MagicMock()
|
||||
request_body = {"params": {"query": ""}}
|
||||
workflow_converter = MagicMock(
|
||||
build_graph_from_app_config=MagicMock(
|
||||
return_value=(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "http_request_1",
|
||||
"data": {
|
||||
"type": BuiltinNodeTypes.HTTP_REQUEST,
|
||||
"body": {"type": "json", "data": json.dumps(request_body)},
|
||||
},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
{},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
result = RuntimeCompletionWorkflowBuilder(workflow_converter=workflow_converter).build(
|
||||
app_model=app_model,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
http_node = result.graph_dict["nodes"][0]
|
||||
body = json.loads(http_node["data"]["body"]["data"])
|
||||
assert body["params"]["query"] == "{{#sys.query#}}"
|
||||
@ -0,0 +1,167 @@
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.app.apps.base_app_queue_manager import PublishFrom
|
||||
from core.app.apps.completion.graph_event_adapter import CompletionGraphEventAdapter
|
||||
from core.app.entities.queue_entities import (
|
||||
QueueErrorEvent,
|
||||
QueueLLMChunkEvent,
|
||||
QueueMessageEndEvent,
|
||||
QueueRetrieverResourcesEvent,
|
||||
QueueStopEvent,
|
||||
)
|
||||
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus
|
||||
from graphon.graph_events import (
|
||||
GraphRunAbortedEvent,
|
||||
GraphRunFailedEvent,
|
||||
GraphRunSucceededEvent,
|
||||
NodeRunFailedEvent,
|
||||
NodeRunRetrieverResourceEvent,
|
||||
NodeRunStreamChunkEvent,
|
||||
NodeRunSucceededEvent,
|
||||
)
|
||||
from graphon.model_runtime.entities.llm_entities import LLMUsage
|
||||
from graphon.node_events import NodeRunResult
|
||||
|
||||
|
||||
def _adapter() -> tuple[CompletionGraphEventAdapter, MagicMock]:
|
||||
queue_manager = MagicMock()
|
||||
entity = SimpleNamespace(model_conf=SimpleNamespace(model="model"))
|
||||
return (
|
||||
CompletionGraphEventAdapter(application_generate_entity=entity, queue_manager=queue_manager),
|
||||
queue_manager,
|
||||
)
|
||||
|
||||
|
||||
def test_stream_chunk_event_publishes_llm_chunk() -> None:
|
||||
adapter, queue_manager = _adapter()
|
||||
|
||||
adapter.handle_event(
|
||||
NodeRunStreamChunkEvent(
|
||||
id="run",
|
||||
node_id="llm",
|
||||
node_type=BuiltinNodeTypes.LLM,
|
||||
selector=["llm", "text"],
|
||||
chunk="hello",
|
||||
)
|
||||
)
|
||||
|
||||
event = queue_manager.publish.call_args.args[0]
|
||||
assert isinstance(event, QueueLLMChunkEvent)
|
||||
assert event.chunk.delta.message.content == "hello"
|
||||
assert queue_manager.publish.call_args.args[1] == PublishFrom.APPLICATION_MANAGER
|
||||
|
||||
|
||||
def test_stream_chunk_event_skips_final_empty_chunk() -> None:
|
||||
adapter, queue_manager = _adapter()
|
||||
|
||||
adapter.handle_event(
|
||||
NodeRunStreamChunkEvent(
|
||||
id="run",
|
||||
node_id="llm",
|
||||
node_type=BuiltinNodeTypes.LLM,
|
||||
selector=["llm", "text"],
|
||||
chunk="",
|
||||
is_final=True,
|
||||
)
|
||||
)
|
||||
|
||||
queue_manager.publish.assert_not_called()
|
||||
|
||||
|
||||
def test_retriever_resource_event_publishes_legacy_retriever_resources() -> None:
|
||||
adapter, queue_manager = _adapter()
|
||||
|
||||
adapter.handle_event(
|
||||
NodeRunRetrieverResourceEvent(
|
||||
id="run",
|
||||
node_id="knowledge_retrieval",
|
||||
node_type=BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL,
|
||||
retriever_resources=[{"dataset_id": "dataset", "content": "hit"}],
|
||||
context="hit",
|
||||
)
|
||||
)
|
||||
|
||||
event = queue_manager.publish.call_args.args[0]
|
||||
assert isinstance(event, QueueRetrieverResourcesEvent)
|
||||
assert event.retriever_resources[0].dataset_id == "dataset"
|
||||
|
||||
|
||||
def test_llm_success_then_graph_success_publishes_message_end_with_saved_prompt() -> None:
|
||||
adapter, queue_manager = _adapter()
|
||||
usage = LLMUsage.empty_usage()
|
||||
saved_prompt = [{"role": "user", "text": "saved prompt"}]
|
||||
adapter.handle_event(
|
||||
NodeRunSucceededEvent(
|
||||
id="run",
|
||||
node_id="llm",
|
||||
node_type=BuiltinNodeTypes.LLM,
|
||||
start_at=datetime.now(UTC),
|
||||
node_run_result=NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
outputs={"text": "final"},
|
||||
process_data={"prompts": saved_prompt},
|
||||
llm_usage=usage,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
adapter.handle_event(GraphRunSucceededEvent(outputs={"result": "final"}))
|
||||
|
||||
event = queue_manager.publish.call_args.args[0]
|
||||
assert isinstance(event, QueueMessageEndEvent)
|
||||
assert event.llm_result is not None
|
||||
assert event.llm_result.message.content == "final"
|
||||
assert event.llm_result.usage is usage
|
||||
assert event.saved_prompt == saved_prompt
|
||||
|
||||
|
||||
def test_graph_success_uses_outputs_result_when_llm_success_was_not_seen() -> None:
|
||||
adapter, queue_manager = _adapter()
|
||||
|
||||
adapter.handle_event(GraphRunSucceededEvent(outputs={"result": "final from graph"}))
|
||||
|
||||
event = queue_manager.publish.call_args.args[0]
|
||||
assert isinstance(event, QueueMessageEndEvent)
|
||||
assert event.llm_result is not None
|
||||
assert event.llm_result.message.content == "final from graph"
|
||||
assert event.saved_prompt == []
|
||||
|
||||
|
||||
def test_failed_node_publishes_error() -> None:
|
||||
adapter, queue_manager = _adapter()
|
||||
|
||||
adapter.handle_event(
|
||||
NodeRunFailedEvent(
|
||||
id="run",
|
||||
node_id="llm",
|
||||
node_type=BuiltinNodeTypes.LLM,
|
||||
error="node boom",
|
||||
start_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
|
||||
event = queue_manager.publish.call_args.args[0]
|
||||
assert isinstance(event, QueueErrorEvent)
|
||||
assert str(event.error) == "node boom"
|
||||
|
||||
|
||||
def test_failed_graph_publishes_error() -> None:
|
||||
adapter, queue_manager = _adapter()
|
||||
|
||||
adapter.handle_event(GraphRunFailedEvent(error="boom"))
|
||||
|
||||
event = queue_manager.publish.call_args.args[0]
|
||||
assert isinstance(event, QueueErrorEvent)
|
||||
assert str(event.error) == "boom"
|
||||
|
||||
|
||||
def test_user_abort_publishes_legacy_stop() -> None:
|
||||
adapter, queue_manager = _adapter()
|
||||
|
||||
adapter.handle_event(GraphRunAbortedEvent(reason="Stopped by user."))
|
||||
|
||||
event = queue_manager.publish.call_args.args[0]
|
||||
assert isinstance(event, QueueStopEvent)
|
||||
assert event.stopped_by == QueueStopEvent.StopBy.USER_MANUAL
|
||||
@ -0,0 +1,270 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.completion.workflow_runner import CompletionWorkflowRunner, ModeratedCompletionInputs
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.moderation.base import ModerationError
|
||||
from core.workflow.node_runtime import DIFY_BEFORE_LLM_INVOKE_KEY
|
||||
from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent
|
||||
from models.model import AppMode
|
||||
from models.provider import ProviderType
|
||||
|
||||
|
||||
def _entity() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
app_config=SimpleNamespace(app_id="app", tenant_id="tenant", prompt_template=MagicMock()),
|
||||
model_conf=SimpleNamespace(model="model"),
|
||||
user_id="user",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
task_id="task",
|
||||
call_depth=2,
|
||||
inputs={"name": "Ada"},
|
||||
query="question",
|
||||
files=[],
|
||||
file_upload_config=None,
|
||||
extras={"trace_session_id": "trace"},
|
||||
stream=True,
|
||||
trace_manager=None,
|
||||
)
|
||||
|
||||
|
||||
def test_runner_builds_workflow_entry_and_adapts_events(monkeypatch) -> None:
|
||||
from core.app.apps.completion import workflow_runner as module
|
||||
|
||||
app = SimpleNamespace(id="app", tenant_id="tenant", mode=AppMode.COMPLETION)
|
||||
entity = _entity()
|
||||
message = SimpleNamespace(id="message", conversation_id="conv")
|
||||
queue_manager = SimpleNamespace(graph_runtime_state=None)
|
||||
runtime_workflow = SimpleNamespace(
|
||||
workflow_id="completion-runtime-1",
|
||||
root_node_id="start",
|
||||
graph_dict={"nodes": [{"id": "start", "data": {"type": "start"}}], "edges": []},
|
||||
)
|
||||
builder = MagicMock(build=MagicMock(return_value=runtime_workflow))
|
||||
graph = MagicMock()
|
||||
adapter = MagicMock()
|
||||
workflow_entry = MagicMock()
|
||||
workflow_entry.run.return_value = iter(["event"])
|
||||
|
||||
init_graph = MagicMock(return_value=graph)
|
||||
workflow_entry_class = MagicMock(return_value=workflow_entry)
|
||||
adapter_class = MagicMock(return_value=adapter)
|
||||
build_system_variables = MagicMock(return_value=["sys"])
|
||||
build_bootstrap_variables = MagicMock(return_value=["boot"])
|
||||
add_variables_to_pool = MagicMock()
|
||||
add_node_inputs_to_pool = MagicMock()
|
||||
|
||||
monkeypatch.setattr(module, "init_graph", init_graph)
|
||||
monkeypatch.setattr(module, "WorkflowEntry", workflow_entry_class)
|
||||
monkeypatch.setattr(module, "CompletionGraphEventAdapter", adapter_class)
|
||||
monkeypatch.setattr(module, "RedisChannel", MagicMock())
|
||||
monkeypatch.setattr(module, "redis_client", MagicMock())
|
||||
monkeypatch.setattr(module, "build_system_variables", build_system_variables)
|
||||
monkeypatch.setattr(module, "build_bootstrap_variables", build_bootstrap_variables)
|
||||
monkeypatch.setattr(module, "add_variables_to_pool", add_variables_to_pool)
|
||||
monkeypatch.setattr(module, "add_node_inputs_to_pool", add_node_inputs_to_pool)
|
||||
|
||||
runner = CompletionWorkflowRunner(runtime_workflow_builder=builder)
|
||||
monkeypatch.setattr(runner, "_get_app", MagicMock(return_value=app))
|
||||
hosting_hook = MagicMock()
|
||||
build_hosting_hook = MagicMock(return_value=hosting_hook)
|
||||
monkeypatch.setattr(runner, "_should_check_hosting_moderation", MagicMock(return_value=True))
|
||||
monkeypatch.setattr(runner, "_build_hosting_moderation_hook", build_hosting_hook)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_run_input_moderation",
|
||||
MagicMock(return_value=ModeratedCompletionInputs(stopped=False, inputs={"name": "Grace"}, query="moderated")),
|
||||
)
|
||||
|
||||
runner.run(application_generate_entity=entity, queue_manager=queue_manager, message=message)
|
||||
|
||||
builder.build.assert_called_once_with(app_model=app, app_config=entity.app_config)
|
||||
add_node_inputs_to_pool.assert_called_once()
|
||||
assert add_node_inputs_to_pool.call_args.kwargs["node_id"] == "start"
|
||||
assert add_node_inputs_to_pool.call_args.kwargs["inputs"] == {"name": "Grace"}
|
||||
build_system_variables.assert_called_once()
|
||||
assert build_system_variables.call_args.kwargs["query"] == "moderated"
|
||||
assert build_system_variables.call_args.kwargs["conversation_id"] == "conv"
|
||||
workflow_entry_class.assert_called_once()
|
||||
assert workflow_entry_class.call_args.kwargs["workflow_id"] == "completion-runtime-1"
|
||||
assert workflow_entry_class.call_args.kwargs["user_from"] == UserFrom.END_USER
|
||||
assert workflow_entry_class.call_args.kwargs["call_depth"] == 2
|
||||
build_hosting_hook.assert_called_once_with(
|
||||
application_generate_entity=entity,
|
||||
queue_manager=queue_manager,
|
||||
)
|
||||
init_graph.assert_called_once()
|
||||
assert init_graph.call_args.kwargs["app_id"] == "app"
|
||||
assert init_graph.call_args.kwargs["graph_config"] == runtime_workflow.graph_dict
|
||||
assert init_graph.call_args.kwargs["root_node_id"] == "start"
|
||||
assert init_graph.call_args.kwargs["call_depth"] == 2
|
||||
assert init_graph.call_args.kwargs["extra_context"][DIFY_BEFORE_LLM_INVOKE_KEY] is hosting_hook
|
||||
workflow_entry.graph_engine.layer.assert_not_called()
|
||||
adapter_class.assert_called_once_with(application_generate_entity=entity, queue_manager=queue_manager)
|
||||
adapter.handle_event.assert_called_once_with("event")
|
||||
|
||||
|
||||
def test_runner_returns_when_input_moderation_stops(monkeypatch) -> None:
|
||||
app = SimpleNamespace(id="app", tenant_id="tenant", mode=AppMode.COMPLETION)
|
||||
entity = _entity()
|
||||
builder = MagicMock()
|
||||
runner = CompletionWorkflowRunner(runtime_workflow_builder=builder)
|
||||
monkeypatch.setattr(runner, "_get_app", MagicMock(return_value=app))
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_run_input_moderation",
|
||||
MagicMock(return_value=ModeratedCompletionInputs(stopped=True, inputs={}, query="")),
|
||||
)
|
||||
|
||||
runner.run(
|
||||
application_generate_entity=entity,
|
||||
queue_manager=MagicMock(),
|
||||
message=SimpleNamespace(id="message"),
|
||||
)
|
||||
|
||||
builder.build.assert_not_called()
|
||||
|
||||
|
||||
def test_runner_get_app_raises_when_record_is_missing(monkeypatch) -> None:
|
||||
from core.app.apps.completion import workflow_runner as module
|
||||
|
||||
runner = CompletionWorkflowRunner(runtime_workflow_builder=MagicMock())
|
||||
monkeypatch.setattr(module.db.session, "scalar", MagicMock(return_value=None))
|
||||
|
||||
with pytest.raises(ValueError, match="App not found"):
|
||||
runner._get_app("missing-app")
|
||||
|
||||
|
||||
def test_runner_get_app_returns_record(monkeypatch) -> None:
|
||||
from core.app.apps.completion import workflow_runner as module
|
||||
|
||||
app = SimpleNamespace(id="app")
|
||||
runner = CompletionWorkflowRunner(runtime_workflow_builder=MagicMock())
|
||||
monkeypatch.setattr(module.db.session, "scalar", MagicMock(return_value=app))
|
||||
|
||||
assert runner._get_app("app") is app
|
||||
|
||||
|
||||
def test_runner_direct_outputs_on_input_moderation() -> None:
|
||||
runner = CompletionWorkflowRunner(runtime_workflow_builder=MagicMock())
|
||||
app_record = SimpleNamespace(id="app", tenant_id="tenant")
|
||||
entity = _entity()
|
||||
message = SimpleNamespace(id="message")
|
||||
queue_manager = MagicMock()
|
||||
runner.organize_prompt_messages = MagicMock(return_value=(["prompt"], None))
|
||||
runner.moderation_for_inputs = MagicMock(side_effect=ModerationError("blocked"))
|
||||
runner.direct_output = MagicMock()
|
||||
|
||||
result = runner._run_input_moderation(
|
||||
app_record=app_record,
|
||||
application_generate_entity=entity,
|
||||
queue_manager=queue_manager,
|
||||
message=message,
|
||||
)
|
||||
|
||||
assert result.stopped is True
|
||||
assert result.inputs == {"name": "Ada"}
|
||||
assert result.query == "question"
|
||||
runner.direct_output.assert_called_once()
|
||||
|
||||
|
||||
def test_runner_returns_moderated_inputs_when_input_moderation_passes() -> None:
|
||||
runner = CompletionWorkflowRunner(runtime_workflow_builder=MagicMock())
|
||||
app_record = SimpleNamespace(id="app", tenant_id="tenant")
|
||||
entity = _entity()
|
||||
message = SimpleNamespace(id="message")
|
||||
runner.organize_prompt_messages = MagicMock(return_value=(["prompt"], None))
|
||||
runner.moderation_for_inputs = MagicMock(return_value=(None, {"name": "Grace"}, "moderated query"))
|
||||
|
||||
result = runner._run_input_moderation(
|
||||
app_record=app_record,
|
||||
application_generate_entity=entity,
|
||||
queue_manager=MagicMock(),
|
||||
message=message,
|
||||
)
|
||||
|
||||
assert result == ModeratedCompletionInputs(stopped=False, inputs={"name": "Grace"}, query="moderated query")
|
||||
|
||||
|
||||
def test_runner_hosting_moderation_hook_uses_final_prompt() -> None:
|
||||
runner = CompletionWorkflowRunner(runtime_workflow_builder=MagicMock())
|
||||
entity = _entity()
|
||||
queue_manager = MagicMock()
|
||||
runner.check_hosting_moderation = MagicMock(return_value=True)
|
||||
|
||||
hook = runner._build_hosting_moderation_hook(
|
||||
application_generate_entity=entity,
|
||||
queue_manager=queue_manager,
|
||||
)
|
||||
|
||||
with pytest.raises(GenerateTaskStoppedError):
|
||||
hook(["final prompt"])
|
||||
|
||||
runner.check_hosting_moderation.assert_called_once_with(
|
||||
application_generate_entity=entity,
|
||||
queue_manager=queue_manager,
|
||||
prompt_messages=["final prompt"],
|
||||
)
|
||||
|
||||
|
||||
def test_runner_should_not_check_hosting_moderation_when_config_is_disabled(monkeypatch) -> None:
|
||||
from core.app.apps.completion import workflow_runner as module
|
||||
|
||||
runner = CompletionWorkflowRunner(runtime_workflow_builder=MagicMock())
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"hosting_configuration",
|
||||
SimpleNamespace(
|
||||
moderation_config=SimpleNamespace(enabled=False),
|
||||
provider_map={},
|
||||
),
|
||||
)
|
||||
|
||||
assert runner._should_check_hosting_moderation(_entity()) is False
|
||||
|
||||
|
||||
def test_runner_should_check_hosting_moderation_for_system_provider(monkeypatch) -> None:
|
||||
from core.app.apps.completion import workflow_runner as module
|
||||
|
||||
entity = _entity()
|
||||
entity.model_conf = SimpleNamespace(
|
||||
provider="openai",
|
||||
provider_model_bundle=SimpleNamespace(
|
||||
configuration=SimpleNamespace(using_provider_type=ProviderType.SYSTEM),
|
||||
),
|
||||
)
|
||||
runner = CompletionWorkflowRunner(runtime_workflow_builder=MagicMock())
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"hosting_configuration",
|
||||
SimpleNamespace(
|
||||
moderation_config=SimpleNamespace(enabled=True, providers=["openai"]),
|
||||
provider_map={
|
||||
f"{module.DEFAULT_PLUGIN_ID}/openai/openai": SimpleNamespace(
|
||||
enabled=True,
|
||||
credentials={"api_key": "secret"},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
assert runner._should_check_hosting_moderation(entity) is True
|
||||
|
||||
|
||||
def test_runner_resolves_account_user_from() -> None:
|
||||
entity = _entity()
|
||||
entity.invoke_from = InvokeFrom.EXPLORE
|
||||
|
||||
assert CompletionWorkflowRunner._resolve_user_from(entity) == UserFrom.ACCOUNT
|
||||
|
||||
|
||||
def test_runner_resolves_configured_image_detail() -> None:
|
||||
entity = _entity()
|
||||
entity.file_upload_config = SimpleNamespace(
|
||||
image_config=SimpleNamespace(detail=ImagePromptMessageContent.DETAIL.HIGH),
|
||||
)
|
||||
|
||||
assert CompletionWorkflowRunner._resolve_image_detail_config(entity) == ImagePromptMessageContent.DETAIL.HIGH
|
||||
@ -5,7 +5,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
|
||||
from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner, init_graph
|
||||
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom
|
||||
from core.app.entities.queue_entities import (
|
||||
QueueAgentLogEvent,
|
||||
@ -117,6 +117,40 @@ class TestWorkflowBasedAppRunner:
|
||||
|
||||
assert captured["run_context"][DIFY_RUN_CONTEXT_KEY].trace_session_id == "session-1"
|
||||
|
||||
def test_init_graph_accepts_call_depth_and_extra_context(self, monkeypatch: pytest.MonkeyPatch):
|
||||
runtime_state = GraphRuntimeState(
|
||||
variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()),
|
||||
start_at=0.0,
|
||||
)
|
||||
hook = object()
|
||||
captured = {}
|
||||
|
||||
def fake_from_graph_init_context(**kwargs):
|
||||
graph_init_context = kwargs["graph_init_context"]
|
||||
captured["run_context"] = graph_init_context.run_context
|
||||
captured["call_depth"] = graph_init_context.call_depth
|
||||
return SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow_app_runner.DifyNodeFactory.from_graph_init_context",
|
||||
fake_from_graph_init_context,
|
||||
)
|
||||
monkeypatch.setattr("core.app.apps.workflow_app_runner.Graph.init", lambda **_kwargs: SimpleNamespace())
|
||||
|
||||
init_graph(
|
||||
app_id="app",
|
||||
graph_config={"nodes": [], "edges": []},
|
||||
graph_runtime_state=runtime_state,
|
||||
user_from=UserFrom.ACCOUNT,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
root_node_id="root",
|
||||
call_depth=2,
|
||||
extra_context={"hook": hook},
|
||||
)
|
||||
|
||||
assert captured["call_depth"] == 2
|
||||
assert captured["run_context"]["hook"] is hook
|
||||
|
||||
def test_prepare_single_node_execution_requires_run(self):
|
||||
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
|
||||
|
||||
|
||||
@ -412,6 +412,51 @@ class TestEasyUiBasedGenerateTaskPipeline:
|
||||
assert isinstance(responses[-1], MessageEndStreamResponse)
|
||||
assert pipeline._task_state.llm_result.message.content == "done"
|
||||
|
||||
def test_process_stream_response_carries_saved_prompt_from_message_end(self, monkeypatch: pytest.MonkeyPatch):
|
||||
pipeline, _ = _make_pipeline()
|
||||
saved_prompt = [{"role": "user", "text": "serialized by graphon"}]
|
||||
llm_result = LLMResult(
|
||||
model="mock",
|
||||
prompt_messages=[],
|
||||
message=AssistantPromptMessage(content="done"),
|
||||
usage=LLMUsage.empty_usage(),
|
||||
)
|
||||
_set_queue_events(
|
||||
pipeline,
|
||||
[_queue_message(QueueMessageEndEvent(llm_result=llm_result, saved_prompt=saved_prompt))],
|
||||
)
|
||||
_set_method(pipeline, "handle_output_moderation_when_task_finished", lambda completion: None)
|
||||
_set_method(
|
||||
pipeline,
|
||||
"_message_end_to_stream_response",
|
||||
lambda: MessageEndStreamResponse(task_id="task", id="msg"),
|
||||
)
|
||||
|
||||
def _save_message(**kwargs):
|
||||
assert pipeline._task_state.saved_prompt == saved_prompt
|
||||
|
||||
_set_method(pipeline, "_save_message", _save_message)
|
||||
|
||||
class _Session:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.sessionmaker",
|
||||
lambda **kwargs: type("_SessionFactory", (), {"begin": lambda self: _Session()})(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db",
|
||||
_FakeDb(),
|
||||
)
|
||||
|
||||
responses = list(pipeline._process_stream_response(publisher=None))
|
||||
|
||||
assert isinstance(responses[-1], MessageEndStreamResponse)
|
||||
|
||||
def test_handle_output_moderation_chunk_directs_output(self):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
message = _make_message()
|
||||
@ -1207,6 +1252,33 @@ class TestEasyUiBasedGenerateTaskPipeline:
|
||||
assert trace_task.kwargs["trace_session_id"] == "session-1"
|
||||
assert len(sent_payloads) == 1
|
||||
|
||||
def test_save_message_uses_saved_prompt_override(self, monkeypatch: pytest.MonkeyPatch):
|
||||
pipeline, _ = _make_pipeline()
|
||||
_set_method(pipeline, "_model_config", _ModelConfigMode(mode="chat"))
|
||||
pipeline._task_state.saved_prompt = [{"role": "user", "text": "serialized by graphon"}]
|
||||
pipeline._task_state.llm_result.message = AssistantPromptMessage(content="answer")
|
||||
pipeline._task_state.llm_result.usage = LLMUsage.empty_usage()
|
||||
|
||||
message_obj = _make_message()
|
||||
conversation_obj = _make_conversation(AppMode.CHAT)
|
||||
session = Mock()
|
||||
session.scalar.side_effect = [message_obj, conversation_obj]
|
||||
|
||||
serialize_mock = Mock(side_effect=AssertionError("saved prompt override should be used"))
|
||||
monkeypatch.setattr(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.PromptMessageUtil.prompt_messages_to_prompt_for_saving",
|
||||
serialize_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.message_was_created.send",
|
||||
lambda *args, **kwargs: None,
|
||||
)
|
||||
|
||||
pipeline._save_message(session=session)
|
||||
|
||||
assert message_obj.message == [{"role": "user", "text": "serialized by graphon"}]
|
||||
serialize_mock.assert_not_called()
|
||||
|
||||
def test_save_message_raises_when_message_not_found(self):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
message = _make_message()
|
||||
|
||||
@ -483,6 +483,7 @@ class TestDifyNodeFactoryCreateNode:
|
||||
def factory(self):
|
||||
factory = object.__new__(node_factory.DifyNodeFactory)
|
||||
factory.graph_init_params = sentinel.graph_init_params
|
||||
factory.graph_init_params.run_context = {}
|
||||
factory.graph_runtime_state = SimpleNamespace(variable_pool=MagicMock())
|
||||
factory._dify_context = SimpleNamespace(
|
||||
tenant_id="tenant-id",
|
||||
@ -723,6 +724,43 @@ class TestDifyNodeFactoryCreateNode:
|
||||
wrap_model.assert_called_once_with(
|
||||
node_data=node_data,
|
||||
model_instance=sentinel.model_instance,
|
||||
before_invoke=None,
|
||||
)
|
||||
assert kwargs["model_instance"] is wrapped_model_instance
|
||||
|
||||
def test_build_llm_compatible_node_init_kwargs_passes_before_llm_hook(self, factory):
|
||||
before_llm_invoke = MagicMock()
|
||||
factory.graph_init_params.run_context[node_factory.DIFY_BEFORE_LLM_INVOKE_KEY] = before_llm_invoke
|
||||
node_data = LLMNodeData.model_validate(
|
||||
{
|
||||
"type": BuiltinNodeTypes.LLM,
|
||||
"title": "LLM",
|
||||
"model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}},
|
||||
"prompt_template": [{"role": "system", "text": "x"}],
|
||||
"context": {"enabled": False, "variable_selector": []},
|
||||
"vision": {"enabled": False},
|
||||
}
|
||||
)
|
||||
wrapped_model_instance = sentinel.wrapped_model_instance
|
||||
factory._build_model_instance_for_llm_node = MagicMock(return_value=sentinel.model_instance)
|
||||
factory._build_memory_for_llm_node = MagicMock(return_value=sentinel.memory)
|
||||
|
||||
with patch.object(factory, "_wrap_model_instance_for_node", return_value=wrapped_model_instance) as wrap_model:
|
||||
kwargs = factory._build_llm_compatible_node_init_kwargs(
|
||||
node_class=sentinel.node_class,
|
||||
node_data=node_data,
|
||||
wrap_model_instance=True,
|
||||
include_http_client=False,
|
||||
include_llm_file_saver=False,
|
||||
include_prompt_message_serializer=False,
|
||||
include_retriever_attachment_loader=False,
|
||||
include_jinja2_template_renderer=False,
|
||||
)
|
||||
|
||||
wrap_model.assert_called_once_with(
|
||||
node_data=node_data,
|
||||
model_instance=sentinel.model_instance,
|
||||
before_invoke=before_llm_invoke,
|
||||
)
|
||||
assert kwargs["model_instance"] is wrapped_model_instance
|
||||
|
||||
|
||||
@ -171,7 +171,8 @@ def test_dify_prepared_llm_wraps_model_instance_calls() -> None:
|
||||
model_schema = _build_model_schema()
|
||||
model_instance = _ModelInstanceStub(model_schema=model_schema)
|
||||
model_type_instance = model_instance.model_type_instance
|
||||
prepared = DifyPreparedLLM(model_instance)
|
||||
before_invoke = Mock()
|
||||
prepared = DifyPreparedLLM(model_instance, before_invoke=before_invoke)
|
||||
|
||||
assert prepared.provider == "langgenius/openai/openai"
|
||||
assert prepared.model_name == "gpt-4o-mini"
|
||||
@ -191,6 +192,7 @@ def test_dify_prepared_llm_wraps_model_instance_calls() -> None:
|
||||
)
|
||||
|
||||
model_type_instance.get_model_schema.assert_called_once_with("gpt-4o-mini", {"api_key": "secret"})
|
||||
before_invoke.assert_called_once_with([])
|
||||
model_instance.invoke_llm.assert_called_once_with(
|
||||
prompt_messages=[],
|
||||
model_parameters={"temperature": 0.1},
|
||||
@ -211,7 +213,8 @@ def test_dify_prepared_llm_requires_model_schema() -> None:
|
||||
|
||||
def test_dify_prepared_llm_delegates_structured_output_helper(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
model_instance = _ModelInstanceStub(model_schema=_build_model_schema())
|
||||
prepared = DifyPreparedLLM(model_instance)
|
||||
before_invoke = Mock()
|
||||
prepared = DifyPreparedLLM(model_instance, before_invoke=before_invoke)
|
||||
invoke_structured = MagicMock(return_value=sentinel.structured)
|
||||
monkeypatch.setattr(node_runtime, "invoke_llm_with_structured_output", invoke_structured)
|
||||
|
||||
@ -224,6 +227,7 @@ def test_dify_prepared_llm_delegates_structured_output_helper(monkeypatch: pytes
|
||||
)
|
||||
|
||||
assert result is sentinel.structured
|
||||
before_invoke.assert_called_once_with([])
|
||||
invoke_structured.assert_called_once_with(
|
||||
provider="langgenius/openai/openai",
|
||||
model_schema=prepared.get_model_schema(),
|
||||
@ -262,7 +266,8 @@ def test_dify_prepared_polling_llm_delegates_to_plugin_runtime() -> None:
|
||||
model_runtime=plugin_runtime,
|
||||
)
|
||||
|
||||
prepared = DifyPreparedPollingLLM(model_instance)
|
||||
before_invoke = Mock()
|
||||
prepared = DifyPreparedPollingLLM(model_instance, before_invoke=before_invoke)
|
||||
|
||||
assert isinstance(prepared, LLMPollingCapableProtocol)
|
||||
assert (
|
||||
@ -275,6 +280,7 @@ def test_dify_prepared_polling_llm_delegates_to_plugin_runtime() -> None:
|
||||
)
|
||||
== polling_result
|
||||
)
|
||||
before_invoke.assert_called_once_with([])
|
||||
assert (
|
||||
prepared.check_llm_polling(
|
||||
plugin_state={"task_id": "poll-1"},
|
||||
|
||||
@ -14,7 +14,7 @@ class TestAppTaskService:
|
||||
("app_mode", "should_call_graph_engine"),
|
||||
[
|
||||
(AppMode.CHAT, False),
|
||||
(AppMode.COMPLETION, False),
|
||||
(AppMode.COMPLETION, True),
|
||||
(AppMode.AGENT_CHAT, False),
|
||||
(AppMode.AGENT, False),
|
||||
(AppMode.CHANNEL, False),
|
||||
|
||||
@ -584,6 +584,94 @@ def test_convert_app_model_config_to_workflow_should_build_workflow_mode_with_en
|
||||
assert set(features.keys()) == {"text_to_speech", "file_upload", "sensitive_word_avoidance"}
|
||||
|
||||
|
||||
def test_build_graph_from_app_config_for_completion_does_not_create_workflow(
|
||||
converter: WorkflowConverter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app_model = _app_model(id="app-1", tenant_id="tenant-1", mode=AppMode.COMPLETION)
|
||||
app_config = SimpleNamespace(
|
||||
variables=[],
|
||||
external_data_variables=[],
|
||||
dataset=None,
|
||||
model=_build_model_config(mode=LLMMode.CHAT),
|
||||
prompt_template=PromptTemplateEntity(
|
||||
prompt_type=PromptTemplateEntity.PromptType.SIMPLE,
|
||||
simple_prompt_template="Hello",
|
||||
),
|
||||
additional_features=None,
|
||||
app_model_config_dict={
|
||||
"text_to_speech": {"enabled": False},
|
||||
"file_upload": {"enabled": False},
|
||||
"sensitive_word_avoidance": {"enabled": False},
|
||||
},
|
||||
)
|
||||
db_session = SimpleNamespace(add=MagicMock(), commit=MagicMock())
|
||||
monkeypatch.setattr(converter_module, "db", SimpleNamespace(session=db_session))
|
||||
|
||||
graph, features = converter.build_graph_from_app_config(
|
||||
app_model=app_model,
|
||||
app_config=app_config,
|
||||
target_app_mode=AppMode.WORKFLOW,
|
||||
)
|
||||
|
||||
assert [node["id"] for node in graph["nodes"]] == ["start", "llm", "end"]
|
||||
assert features == {
|
||||
"text_to_speech": {"enabled": False},
|
||||
"file_upload": {"enabled": False},
|
||||
"sensitive_word_avoidance": {"enabled": False},
|
||||
}
|
||||
db_session.add.assert_not_called()
|
||||
db_session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_build_graph_from_app_config_preserves_api_based_variable_nodes(
|
||||
converter: WorkflowConverter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app_model = _app_model(id="app-1", tenant_id="tenant-1", mode=AppMode.COMPLETION)
|
||||
app_config = SimpleNamespace(
|
||||
variables=[VariableEntity(variable="city", label="City", type=VariableEntityType.TEXT_INPUT)],
|
||||
external_data_variables=[
|
||||
ExternalDataVariableEntity(
|
||||
variable="weather",
|
||||
type="api",
|
||||
config={"api_based_extension_id": "api_based_extension_id"},
|
||||
)
|
||||
],
|
||||
dataset=None,
|
||||
model=_build_model_config(mode=LLMMode.CHAT),
|
||||
prompt_template=PromptTemplateEntity(
|
||||
prompt_type=PromptTemplateEntity.PromptType.SIMPLE,
|
||||
simple_prompt_template="Weather: {{weather}}",
|
||||
),
|
||||
additional_features=None,
|
||||
app_model_config_dict={},
|
||||
)
|
||||
extension = SimpleNamespace(
|
||||
name="Weather API",
|
||||
api_endpoint="https://example.com/weather",
|
||||
api_key="encrypted-token",
|
||||
)
|
||||
monkeypatch.setattr(converter, "_get_api_based_extension", MagicMock(return_value=extension))
|
||||
monkeypatch.setattr(converter_module.encrypter, "decrypt_token", MagicMock(return_value="plain-token"))
|
||||
|
||||
graph, _ = converter.build_graph_from_app_config(
|
||||
app_model=app_model,
|
||||
app_config=app_config,
|
||||
target_app_mode=AppMode.WORKFLOW,
|
||||
)
|
||||
|
||||
assert [node["data"]["type"] for node in graph["nodes"]] == [
|
||||
BuiltinNodeTypes.START,
|
||||
BuiltinNodeTypes.HTTP_REQUEST,
|
||||
BuiltinNodeTypes.CODE,
|
||||
BuiltinNodeTypes.LLM,
|
||||
BuiltinNodeTypes.END,
|
||||
]
|
||||
llm_node = next(node for node in graph["nodes"] if node["id"] == "llm")
|
||||
assert "{{#code_1.result#}}" in llm_node["data"]["prompt_template"][0]["text"]
|
||||
|
||||
|
||||
def test_convert_to_app_config_should_route_to_correct_manager(
|
||||
converter: WorkflowConverter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@langgenius/difyctl",
|
||||
"type": "module",
|
||||
"version": "0.2.0-alpha",
|
||||
"version": "0.1.0-alpha",
|
||||
"description": "Dify command-line interface",
|
||||
"difyctl": {
|
||||
"channel": "alpha",
|
||||
"compat": {
|
||||
"minDify": "1.16.0",
|
||||
"maxDify": "1.16.0"
|
||||
"minDify": "1.15.0",
|
||||
"maxDify": "1.15.0"
|
||||
},
|
||||
"release": {
|
||||
"tagPrefix": "difyctl-v",
|
||||
|
||||
@ -15,41 +15,41 @@ function run(args: string[]): { code: number, stdout: string, stderr: string } {
|
||||
}
|
||||
}
|
||||
|
||||
describe('release-naming compat-check (compat 1.16.0..1.16.0)', () => {
|
||||
describe('release-naming compat-check (compat 1.15.0..1.15.0)', () => {
|
||||
it('accepts a version inside the window', () => {
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
expect(run(['compat-check', '1.15.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive lower bound', () => {
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
expect(run(['compat-check', '1.15.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive upper bound', () => {
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
expect(run(['compat-check', '1.15.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts a v-prefixed tag', () => {
|
||||
expect(run(['compat-check', 'v1.16.0']).code).toBe(0)
|
||||
expect(run(['compat-check', 'v1.15.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version below the lower bound', () => {
|
||||
expect(run(['compat-check', '1.15.9']).code).not.toBe(0)
|
||||
expect(run(['compat-check', '1.14.9']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version above the upper bound', () => {
|
||||
expect(run(['compat-check', '1.16.1']).code).not.toBe(0)
|
||||
expect(run(['compat-check', '1.15.1']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('treats a prerelease of the bound as below it (1.16.0-rc1 < 1.16.0)', () => {
|
||||
expect(run(['compat-check', '1.16.0-rc1']).code).not.toBe(0)
|
||||
it('treats a prerelease of the bound as below it (1.15.0-rc1 < 1.15.0)', () => {
|
||||
expect(run(['compat-check', '1.15.0-rc1']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata on the bound (1.16.0+build == 1.16.0)', () => {
|
||||
expect(run(['compat-check', '1.16.0+build123']).code).toBe(0)
|
||||
it('ignores build metadata on the bound (1.15.0+build == 1.15.0)', () => {
|
||||
expect(run(['compat-check', '1.15.0+build123']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata when out of range (1.16.1+build still rejected)', () => {
|
||||
expect(run(['compat-check', '1.16.1+build123']).code).not.toBe(0)
|
||||
it('ignores build metadata when out of range (1.15.1+build still rejected)', () => {
|
||||
expect(run(['compat-check', '1.15.1+build123']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('requires a version argument', () => {
|
||||
@ -60,7 +60,7 @@ describe('release-naming compat-check (compat 1.16.0..1.16.0)', () => {
|
||||
describe('release-naming github-env', () => {
|
||||
it('emits difyctlTag = tagPrefix + version', () => {
|
||||
const { stdout } = run(['github-env'])
|
||||
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.2\.0-alpha$/m)
|
||||
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.1\.0-alpha$/m)
|
||||
})
|
||||
|
||||
it('still emits the existing trace fields', () => {
|
||||
@ -75,14 +75,14 @@ describe('release-naming edge channel', () => {
|
||||
expect(run(['channels']).stdout).toMatch(/^edge$/m)
|
||||
})
|
||||
|
||||
it('edge-version derives <pkgcore>-edge.<sha> from the package version', () => {
|
||||
// package.json version is 0.2.0-alpha -> core 0.2.0
|
||||
expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.2.0-edge.2fd7b82')
|
||||
it('edge-version derives <pkgcore>-edge.<sha> stripping the alpha prerelease', () => {
|
||||
// package.json version is 0.1.0-alpha -> core 0.1.0
|
||||
expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.1.0-edge.2fd7b82')
|
||||
})
|
||||
|
||||
it('edge-version accepts a 40-char sha', () => {
|
||||
const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000'
|
||||
expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.2.0-edge.${sha}`)
|
||||
expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.1.0-edge.${sha}`)
|
||||
})
|
||||
|
||||
it('edge-version rejects a non-hex sha', () => {
|
||||
|
||||
@ -81,7 +81,7 @@ describe('release-r2-edge manifest', () => {
|
||||
|
||||
it('carries the compat window from package.json', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(json.compat).toEqual({ minDify: '1.16.0', maxDify: '1.16.0' })
|
||||
expect(json.compat).toEqual({ minDify: '1.15.0', maxDify: '1.15.0' })
|
||||
})
|
||||
|
||||
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {
|
||||
|
||||
@ -26,7 +26,7 @@ describe('AppDslClient.exportDsl', () => {
|
||||
const yaml = await makeClient(stub.url).exportDsl('app-1')
|
||||
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/dsl')
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/export')
|
||||
expect(yaml).toBe(DSL_YAML)
|
||||
})
|
||||
|
||||
@ -90,7 +90,7 @@ describe('AppDslClient.confirmImport', () => {
|
||||
const result = await makeClient(stub.url).confirmImport('ws-1', 'imp-1')
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports/imp-1:confirm')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports/imp-1/confirm')
|
||||
expect(result.status).toBe('completed')
|
||||
})
|
||||
})
|
||||
@ -107,7 +107,7 @@ describe('AppDslClient.checkDependencies', () => {
|
||||
|
||||
const result = await makeClient(stub.url).checkDependencies('app-1')
|
||||
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/dependencies:check')
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/check-dependencies')
|
||||
expect(result.leaked_dependencies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -33,7 +33,7 @@ export class AppDslClient {
|
||||
}
|
||||
|
||||
async exportDsl(appId: string, query?: ExportQuery): Promise<string> {
|
||||
const resp = await this.orpc.apps.byAppId.dsl.get({
|
||||
const resp = await this.orpc.apps.byAppId.export.get({
|
||||
params: { app_id: appId },
|
||||
query: query !== undefined
|
||||
? {
|
||||
@ -52,7 +52,7 @@ export class AppDslClient {
|
||||
}
|
||||
|
||||
async checkDependencies(appId: string): Promise<CheckDependenciesResult> {
|
||||
return this.orpc.apps.byAppId.dependencies.check.get({
|
||||
return this.orpc.apps.byAppId.checkDependencies.get({
|
||||
params: { app_id: appId },
|
||||
})
|
||||
}
|
||||
|
||||
@ -54,7 +54,7 @@ export class AppRunClient {
|
||||
body: Record<string, unknown>,
|
||||
opts: StreamOptions = {},
|
||||
): Promise<AsyncIterable<SseEvent>> {
|
||||
const res = await this.http.stream(`apps/${encodeURIComponent(appId)}:run`, {
|
||||
const res = await this.http.stream(`apps/${encodeURIComponent(appId)}/run`, {
|
||||
method: 'POST',
|
||||
json: body,
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
@ -79,7 +79,7 @@ export class AppRunClient {
|
||||
action: string,
|
||||
inputs: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.orpc.apps.byAppId.humanInputForms.byFormToken.submit.post({
|
||||
await this.orpc.apps.byAppId.form.humanInput.byFormToken.post({
|
||||
params: { app_id: appId, form_token: formToken },
|
||||
body: { action, inputs },
|
||||
})
|
||||
|
||||
@ -82,12 +82,12 @@ describe('AppsClient.describe', () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('hits /apps/<id>, omits workspace_id and fields when not given', async () => {
|
||||
it('hits /apps/<id>/describe, omits workspace_id and fields when not given', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
|
||||
const res = await makeClient(stub.url).describe('app-1')
|
||||
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1')
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/describe')
|
||||
const q = queryOf(stub.captured.url)
|
||||
expect(q.has('workspace_id')).toBe(false)
|
||||
expect(q.has('fields')).toBe(false)
|
||||
@ -107,6 +107,6 @@ describe('AppsClient.describe', () => {
|
||||
|
||||
await makeClient(stub.url).describe('app/with space')
|
||||
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app%2Fwith%20space')
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app%2Fwith%20space/describe')
|
||||
})
|
||||
})
|
||||
|
||||
@ -37,7 +37,7 @@ export class AppsClient implements AppReader {
|
||||
}
|
||||
|
||||
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
|
||||
return this.orpc.apps.byAppId.get({
|
||||
return this.orpc.apps.byAppId.describe.get({
|
||||
params: { app_id: appId },
|
||||
query: {
|
||||
fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined,
|
||||
|
||||
@ -41,7 +41,7 @@ describe('FileUploadClient.upload', () => {
|
||||
const result = await makeClient(stub.url).upload('app-1', filePath)
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/apps/app-1/files')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/apps/app-1/files/upload')
|
||||
// The client must let fetch own the multipart Content-Type + boundary; it
|
||||
// must NOT coerce this to application/json the way a json body would.
|
||||
const contentType = stub.captured.headers?.['content-type'] ?? ''
|
||||
@ -61,7 +61,7 @@ describe('FileUploadClient.upload', () => {
|
||||
|
||||
await makeClient(stub.url).upload('app/with space', filePath)
|
||||
|
||||
expect(stub.captured.url).toBe('/openapi/v1/apps/app%2Fwith%20space/files')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/apps/app%2Fwith%20space/files/upload')
|
||||
})
|
||||
|
||||
it('propagates a server 413 as a classified BaseError', async () => {
|
||||
|
||||
@ -65,7 +65,7 @@ export class FileUploadClient {
|
||||
form.append('file', blob, filename)
|
||||
|
||||
return this.http.post<UploadedFile>(
|
||||
`apps/${encodeURIComponent(appId)}/files`,
|
||||
`apps/${encodeURIComponent(appId)}/files/upload`,
|
||||
{ body: form, timeoutMs: 60_000 },
|
||||
)
|
||||
}
|
||||
|
||||
@ -154,13 +154,13 @@ describe('MembersClient.updateRole', () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('PATCHes role payload to the member resource', async () => {
|
||||
it('PUTs role payload to /role subresource', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap))
|
||||
|
||||
const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' })
|
||||
|
||||
expect(stub.captured.method).toBe('PATCH')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1')
|
||||
expect(stub.captured.method).toBe('PUT')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1/role')
|
||||
expect(JSON.parse(stub.captured.body ?? '{}')).toEqual({ role: 'admin' })
|
||||
expect(result.result).toBe('success')
|
||||
})
|
||||
@ -181,7 +181,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('POSTs /workspaces/<id>:switch and returns workspace detail', async () => {
|
||||
it('POSTs /workspaces/<id>/switch and returns workspace detail', async () => {
|
||||
stub = await startStubServer(cap =>
|
||||
jsonResponder(
|
||||
200,
|
||||
@ -200,7 +200,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
|
||||
const result = await client.switch('ws-1')
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1:switch')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/switch')
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@ -47,7 +47,7 @@ export class MembersClient {
|
||||
memberId: string,
|
||||
payload: MemberRoleUpdatePayload,
|
||||
): Promise<MemberActionResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.patch({
|
||||
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.role.put({
|
||||
params: { workspace_id: workspaceId, member_id: memberId },
|
||||
body: payload,
|
||||
})
|
||||
|
||||
@ -12,15 +12,15 @@ describe('PermittedExternalAppsClient', () => {
|
||||
it('list calls permittedExternalApps.get with paging/filter query', async () => {
|
||||
const c = new PermittedExternalAppsClient(fakeHttp())
|
||||
const get = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] })
|
||||
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { get: vi.fn() } } }
|
||||
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { describe: { get: vi.fn() } } } }
|
||||
await c.list({ workspaceId: '', page: 2, limit: 5, mode: undefined, name: 'a' })
|
||||
expect(get).toHaveBeenCalledWith({ query: { page: 2, limit: 5, mode: undefined, name: 'a' } })
|
||||
})
|
||||
|
||||
it('describe calls permittedExternalApps.byAppId.get with app_id + fields', async () => {
|
||||
it('describe calls permittedExternalApps.byAppId.describe.get with app_id + fields', async () => {
|
||||
const c = new PermittedExternalAppsClient(fakeHttp())
|
||||
const dget = vi.fn().mockResolvedValue({ info: null, parameters: null, input_schema: null })
|
||||
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get: vi.fn(), byAppId: { get: dget } } }
|
||||
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get: vi.fn(), byAppId: { describe: { get: dget } } } }
|
||||
await c.describe('app-1', ['info'])
|
||||
expect(dget).toHaveBeenCalledWith({ params: { app_id: 'app-1' }, query: { fields: 'info' } })
|
||||
})
|
||||
|
||||
@ -26,7 +26,7 @@ export class PermittedExternalAppsClient implements AppReader {
|
||||
}
|
||||
|
||||
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
|
||||
return this.orpc.permittedExternalApps.byAppId.get({
|
||||
return this.orpc.permittedExternalApps.byAppId.describe.get({
|
||||
params: { app_id: appId },
|
||||
query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined },
|
||||
})
|
||||
|
||||
@ -19,7 +19,7 @@ export class WorkspacesClient {
|
||||
|
||||
/**
|
||||
* Server-side workspace switch via OpenAPI POST
|
||||
* `/workspaces/{id}:switch` — the bearer-authed equivalent of the
|
||||
* `/workspaces/{id}/switch` — the bearer-authed equivalent of the
|
||||
* console's POST `/workspaces/switch`. The server updates the caller's
|
||||
* `current` tenant_account_join row. Callers MUST refresh their local
|
||||
* `hosts.yml` only after this resolves — never fall back to a local
|
||||
|
||||
57
cli/src/cache/compat-store.test.ts
vendored
57
cli/src/cache/compat-store.test.ts
vendored
@ -1,57 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_COMPAT, getCache } from '@/store/manager'
|
||||
import { loadCompatStore } from './compat-store'
|
||||
|
||||
const HOST = 'https://cloud.dify.ai'
|
||||
const NOW = new Date('2026-05-20T12:00:00.000Z')
|
||||
|
||||
describe('compat-store', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-compat-'))
|
||||
prev = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const store = (now: Date = NOW) => loadCompatStore({ store: getCache(CACHE_COMPAT), now: () => now })
|
||||
|
||||
it('is not fresh before anything is marked', async () => {
|
||||
expect((await store()).isFreshCompatible(HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it('is fresh right after markCompatible, and persists across loads', async () => {
|
||||
await (await store()).markCompatible(HOST)
|
||||
expect((await store()).isFreshCompatible(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays fresh within the 1h TTL', async () => {
|
||||
const past = new Date(NOW.getTime() - 30 * 60 * 1000)
|
||||
await (await store(past)).markCompatible(HOST)
|
||||
expect((await store(NOW)).isFreshCompatible(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('expires after the 1h TTL', async () => {
|
||||
const past = new Date(NOW.getTime() - 61 * 60 * 1000)
|
||||
await (await store(past)).markCompatible(HOST)
|
||||
expect((await store(NOW)).isFreshCompatible(HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks hosts independently', async () => {
|
||||
const s = await store()
|
||||
await s.markCompatible(HOST)
|
||||
expect(s.isFreshCompatible('https://other.dify.ai')).toBe(false)
|
||||
})
|
||||
})
|
||||
71
cli/src/cache/compat-store.ts
vendored
71
cli/src/cache/compat-store.ts
vendored
@ -1,71 +0,0 @@
|
||||
import type { Store } from '@/store/store'
|
||||
import { CACHE_COMPAT, getCache } from '@/store/manager'
|
||||
|
||||
// How long a host stays "known compatible" before difyctl re-probes /_version.
|
||||
export const COMPAT_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
// Only *positive* (compatible) verdicts are cached — never "too old". A host that
|
||||
// was too old is re-probed every time, so a just-upgraded server clears a previous
|
||||
// block immediately instead of staying locked out for the whole TTL.
|
||||
const COMPATIBLE_KEY = { key: 'compatible', default: {} as Record<string, string> } as const
|
||||
|
||||
export type CompatStore = {
|
||||
readonly isFreshCompatible: (host: string, now?: Date) => boolean
|
||||
readonly markCompatible: (host: string, now?: Date) => Promise<void>
|
||||
}
|
||||
|
||||
export type CompatStoreOptions = {
|
||||
readonly store?: Store
|
||||
readonly now?: () => Date
|
||||
readonly ttlMs?: number
|
||||
}
|
||||
|
||||
export async function loadCompatStore(opts: CompatStoreOptions = {}): Promise<CompatStore> {
|
||||
const store = opts.store ?? getCache(CACHE_COMPAT)
|
||||
const ttlMs = opts.ttlMs ?? COMPAT_TTL_MS
|
||||
const clock = opts.now ?? (() => new Date())
|
||||
const memory = await readCompatible(store)
|
||||
|
||||
return {
|
||||
isFreshCompatible: (host, now) => {
|
||||
const last = memory.get(host)
|
||||
if (last === undefined)
|
||||
return false
|
||||
const elapsed = Math.max(0, (now ?? clock()).getTime() - last)
|
||||
return elapsed < ttlMs
|
||||
},
|
||||
markCompatible: async (host, now) => {
|
||||
const stamp = (now ?? clock()).getTime()
|
||||
memory.set(host, stamp)
|
||||
// Re-read disk inside the write cycle so concurrent processes touching
|
||||
// different hosts don't clobber each other's stamps.
|
||||
const onDisk = await readCompatible(store)
|
||||
onDisk.set(host, stamp)
|
||||
await writeCompatible(store, onDisk)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readCompatible(store: Store): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>()
|
||||
let raw: Record<string, string>
|
||||
try {
|
||||
raw = await store.get(COMPATIBLE_KEY)
|
||||
}
|
||||
catch {
|
||||
return out
|
||||
}
|
||||
for (const [host, iso] of Object.entries(raw)) {
|
||||
const t = Date.parse(iso)
|
||||
if (!Number.isNaN(t))
|
||||
out.set(host, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function writeCompatible(store: Store, state: Map<string, number>): Promise<void> {
|
||||
const compatible: Record<string, string> = {}
|
||||
for (const [host, t] of state)
|
||||
compatible[host] = new Date(t).toISOString()
|
||||
await store.set(COMPATIBLE_KEY, compatible)
|
||||
}
|
||||
@ -14,7 +14,6 @@ import { createHttpClient } from '@/http/client'
|
||||
import { getTokenStore } from '@/store/manager'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
import { hostWithScheme, openAPIBase } from '@/util/host'
|
||||
import { enforceDifyVersion } from '@/version/enforce'
|
||||
import { versionInfo } from '@/version/info'
|
||||
import { maybeNudgeCompat } from '@/version/nudge'
|
||||
import { resolveRetryAttempts } from './global-flags.js'
|
||||
@ -56,10 +55,6 @@ export async function buildAuthedContext(
|
||||
|
||||
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
|
||||
|
||||
// Hard gate: refuse a server too old for this difyctl (throws → exit 6).
|
||||
// Cached per host (1h) so most commands don't re-probe. Then the soft nudge
|
||||
// handles the "server too new" direction.
|
||||
await enforceDifyVersion(host)
|
||||
await runCompatNudge({ host, io })
|
||||
|
||||
return { reg, active, store, http, host, io, cache }
|
||||
|
||||
@ -2,7 +2,6 @@ import type { CommandEffect } from '@/framework/command'
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { Flags } from '@/framework/flags'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
import { enforceDifyVersion } from '@/version/enforce'
|
||||
import { agentGuide } from './guide'
|
||||
import { runLogin } from './login'
|
||||
|
||||
@ -39,9 +38,6 @@ export default class Login extends DifyCommand {
|
||||
host: flags.host,
|
||||
noBrowser: flags['no-browser'],
|
||||
insecure: flags.insecure,
|
||||
verifyServer: async (host) => {
|
||||
await enforceDifyVersion(host, { forceFresh: true })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -31,10 +31,6 @@ export type LoginOptions = {
|
||||
readonly browserEnv?: BrowserEnv
|
||||
readonly browserOpener?: BrowserOpener
|
||||
readonly clock?: Clock
|
||||
// Version guard for the freshly-authenticated host; wired to enforceDifyVersion
|
||||
// at the command boundary. Runs before the session is persisted so we never
|
||||
// save credentials for a server too old for this difyctl. Defaults to a no-op.
|
||||
readonly verifyServer?: (host: string) => Promise<void>
|
||||
}
|
||||
|
||||
export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
@ -74,9 +70,6 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
spinner.stop()
|
||||
}
|
||||
|
||||
// Refuse to persist a session to a server too old for this difyctl.
|
||||
await (opts.verifyServer ?? (async () => {}))(host)
|
||||
|
||||
const storeBundle = opts.store ?? await detectTokenStore()
|
||||
const display = bareHost(host)
|
||||
const email = accountEmail(success)
|
||||
|
||||
@ -41,7 +41,7 @@ describe('resumeApp pre-flight subject strategy', () => {
|
||||
const http = {
|
||||
baseURL: 'http://localhost',
|
||||
request: vi.fn().mockImplementation((opts: { path: string }) => {
|
||||
if (typeof opts.path === 'string' && opts.path.includes('human-input-forms')) {
|
||||
if (typeof opts.path === 'string' && opts.path.includes('form/human_input')) {
|
||||
return Promise.resolve(FORM_RESP)
|
||||
}
|
||||
// reconnect stream — return an async iterable that ends immediately
|
||||
|
||||
@ -50,7 +50,7 @@ export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Pr
|
||||
let action = opts.action
|
||||
if (action === undefined) {
|
||||
const formResp = await deps.http.get<{ user_actions: { id: string }[] }>(
|
||||
`apps/${encodeURIComponent(opts.appId)}/human-input-forms/${encodeURIComponent(opts.formToken)}`,
|
||||
`apps/${encodeURIComponent(opts.appId)}/form/human_input/${encodeURIComponent(opts.formToken)}`,
|
||||
)
|
||||
if (formResp.user_actions.length === 1) {
|
||||
action = formResp.user_actions[0]?.id ?? ''
|
||||
|
||||
@ -65,7 +65,7 @@ async function executeRun(
|
||||
const m = await meta.get(opts.appId, [FieldInfo])
|
||||
const mode = m.info?.mode ?? ''
|
||||
if (mode === '')
|
||||
throw new Error(`app ${opts.appId}: mode missing from app metadata`)
|
||||
throw new Error(`app ${opts.appId}: mode missing from /describe`)
|
||||
|
||||
if (mode === RUN_MODES.Workflow && opts.message !== undefined && opts.message !== '') {
|
||||
throw new BaseError({
|
||||
|
||||
@ -27,7 +27,7 @@ export type UseWorkspaceDeps = {
|
||||
* workspace list and let the caller pick one interactively (TTY only).
|
||||
*
|
||||
* The server-side switch is the source of truth: if POST
|
||||
* `/workspaces/<id>:switch` fails we abort before touching `hosts.yml`, so
|
||||
* `/workspaces/<id>/switch` fails we abort before touching `hosts.yml`, so
|
||||
* local state never diverges from the server.
|
||||
*/
|
||||
export async function runUseWorkspace(
|
||||
|
||||
@ -109,7 +109,7 @@ describe('Version command', () => {
|
||||
}
|
||||
|
||||
it('--check-compat exits with COMPAT_FAIL_EXIT_CODE when compat is unsupported', async () => {
|
||||
vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'too_new' }))
|
||||
vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'unsupported' }))
|
||||
const exitSpy = stubProcessExit()
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
@ -119,7 +119,7 @@ describe('Version command', () => {
|
||||
})
|
||||
|
||||
it('--check-compat -o json emits the JSON envelope on stdout before exiting', async () => {
|
||||
vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'too_new' }))
|
||||
vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'unsupported' }))
|
||||
const exitSpy = stubProcessExit()
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
@ -131,7 +131,7 @@ describe('Version command', () => {
|
||||
expect(stdoutSpy).toHaveBeenCalled()
|
||||
const written = stdoutSpy.mock.calls.map(c => String(c[0])).join('')
|
||||
const parsed = JSON.parse(written) as { compat: { status: string } }
|
||||
expect(parsed.compat.status).toBe('too_new')
|
||||
expect(parsed.compat.status).toBe('unsupported')
|
||||
expect(exitSpy).toHaveBeenCalledWith(COMPAT_FAIL_EXIT_CODE)
|
||||
})
|
||||
|
||||
|
||||
@ -184,7 +184,7 @@ describe('http client', () => {
|
||||
const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test' })
|
||||
let caught: unknown
|
||||
try {
|
||||
await client.get('apps/nope')
|
||||
await client.get('apps/nope/describe')
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isHttpClientError(caught)).toBe(true)
|
||||
@ -545,7 +545,7 @@ describe('empty / No-Content bodies', () => {
|
||||
})
|
||||
try {
|
||||
const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test' })
|
||||
await expect(client.post('apps/app-1/tasks/t-1:stop', { json: {} })).resolves.toBeUndefined()
|
||||
await expect(client.post('apps/app-1/tasks/t-1/stop', { json: {} })).resolves.toBeUndefined()
|
||||
}
|
||||
finally {
|
||||
await stub.stop()
|
||||
|
||||
@ -74,7 +74,7 @@ describe('classifyResponse — canonical ErrorBody', () => {
|
||||
|
||||
describe('classifyResponse 403', () => {
|
||||
it('maps 403 to AccessDenied (exit 4 bucket)', async () => {
|
||||
const req403 = new Request('https://x/openapi/v1/apps/abc/dsl')
|
||||
const req403 = new Request('https://x/openapi/v1/apps/abc/export')
|
||||
const res403 = new Response(
|
||||
JSON.stringify({ code: 'unsupported_token_type', message: 'unsupported_token_type', status: 403 }),
|
||||
{ status: 403, headers: { 'content-type': 'application/json' } },
|
||||
@ -91,31 +91,6 @@ describe('classifyResponse 403', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyResponse 426', () => {
|
||||
it('maps 426 to VersionSkew (exit 6) and surfaces the server upgrade message', async () => {
|
||||
const body = {
|
||||
code: 'upgrade_required',
|
||||
message: 'difyctl 0.1.0 is no longer supported; upgrade to >= 0.2.0.',
|
||||
status: 426,
|
||||
hint: 'Upgrade difyctl: https://docs.dify.ai/en/cli/install',
|
||||
}
|
||||
|
||||
const err = await classified(426, body)
|
||||
|
||||
expect(err.code).toBe(ErrorCode.VersionSkew)
|
||||
expect(err.exit()).toBe(6)
|
||||
expect(err.message).toBe('difyctl 0.1.0 is no longer supported; upgrade to >= 0.2.0.')
|
||||
expect(err.serverError?.code).toBe('upgrade_required')
|
||||
})
|
||||
|
||||
it('426 with no parseable ErrorBody falls back to a version message', async () => {
|
||||
const err = await classified(426, 'not json')
|
||||
|
||||
expect(err.code).toBe(ErrorCode.VersionSkew)
|
||||
expect(err.message).toBe('client version no longer supported by the server')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyResponse — non-conforming bodies (no fallback by design)', () => {
|
||||
it('non-JSON body yields no serverError, classification by status', async () => {
|
||||
const err = await classified(502, '<html>bad gateway</html>')
|
||||
|
||||
@ -50,22 +50,11 @@ const ACCESS_DENIED_CLASS: StatusClass = {
|
||||
includeRaw: false,
|
||||
}
|
||||
|
||||
// 426 Upgrade Required: the server rejected this difyctl as too old. Give it the
|
||||
// version-compat exit code so scripts can tell it apart from a generic failure.
|
||||
// The server's ErrorBody.code ("upgrade_required") + message still ride along.
|
||||
const VERSION_COMPAT_CLASS: StatusClass = {
|
||||
code: ErrorCode.VersionSkew,
|
||||
fallbackMessage: () => 'client version no longer supported by the server',
|
||||
includeRaw: false,
|
||||
}
|
||||
|
||||
function statusClass(status: number): StatusClass {
|
||||
if (status === 401)
|
||||
return AUTH_EXPIRED_CLASS
|
||||
if (status === 403)
|
||||
return ACCESS_DENIED_CLASS
|
||||
if (status === 426)
|
||||
return VERSION_COMPAT_CLASS
|
||||
if (status === 429)
|
||||
return RATE_LIMITED_CLASS
|
||||
if (status >= 500)
|
||||
|
||||
@ -7,7 +7,6 @@ import { FileTokenStore, KeychainTokenStore } from './token-store'
|
||||
|
||||
export const CACHE_APP_INFO = 'app-info'
|
||||
export const CACHE_NUDGE = 'nudge'
|
||||
export const CACHE_COMPAT = 'compat'
|
||||
const HOSTS_FILE = 'hosts.yml'
|
||||
const TOKENS_FILE = 'tokens.yml'
|
||||
export const CONFIG_FILE_NAME = 'config.yml'
|
||||
|
||||
@ -32,14 +32,14 @@ describe('evaluateCompat', () => {
|
||||
expect(evaluateCompat('1.7.0', range).status).toBe('compatible')
|
||||
})
|
||||
|
||||
it('returns too_old when server is below minimum', () => {
|
||||
it('returns unsupported when server is below minimum', () => {
|
||||
const v = evaluateCompat('1.5.9', range)
|
||||
expect(v.status).toBe('too_old')
|
||||
expect(v.status).toBe('unsupported')
|
||||
expect(v.detail).toContain('1.5.9')
|
||||
})
|
||||
|
||||
it('returns too_new when server is above maximum', () => {
|
||||
expect(evaluateCompat('2.0.0', range).status).toBe('too_new')
|
||||
it('returns unsupported when server is above maximum', () => {
|
||||
expect(evaluateCompat('2.0.0', range).status).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('returns unknown when server version is empty', () => {
|
||||
|
||||
@ -14,7 +14,7 @@ export function compatString(): string {
|
||||
return `dify >=${difyCompat.minDify}, <=${difyCompat.maxDify}`
|
||||
}
|
||||
|
||||
export type CompatStatus = 'compatible' | 'too_old' | 'too_new' | 'unknown'
|
||||
export type CompatStatus = 'compatible' | 'unsupported' | 'unknown'
|
||||
|
||||
export type CompatVerdict = {
|
||||
readonly status: CompatStatus
|
||||
@ -54,19 +54,5 @@ export function evaluateCompat(
|
||||
if (satisfies(parsedServer, parsedRange))
|
||||
return { status: 'compatible', detail: `server ${serverVersion} in [${range.minDify}, ${range.maxDify}]` }
|
||||
|
||||
// Outside the window. Distinguish too-old (below min → the caller hard-blocks)
|
||||
// from too-new (above max → soft nudge) by testing the lower bound alone; this
|
||||
// reuses `satisfies` so we need no separate version-compare import.
|
||||
const minOnly = (() => {
|
||||
try {
|
||||
return parseRange(`>=${range.minDify}`)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (minOnly !== undefined && !satisfies(parsedServer, minOnly))
|
||||
return { status: 'too_old', detail: `server ${serverVersion} is older than the minimum ${range.minDify}` }
|
||||
|
||||
return { status: 'too_new', detail: `server ${serverVersion} is newer than the tested maximum ${range.maxDify}` }
|
||||
return { status: 'unsupported', detail: `server ${serverVersion} outside [${range.minDify}, ${range.maxDify}]` }
|
||||
}
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { CompatStore } from '@/cache/compat-store'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { enforceDifyVersion } from './enforce'
|
||||
|
||||
// Injected build range in tests is __DIFYCTL_MIN_DIFY__=1.6.0 / MAX=1.7.0 (test/setup.ts):
|
||||
// 1.5.0 → too_old, 1.6.4 → compatible, 99.0.0 → too_new, '' → unknown.
|
||||
const HOST = 'https://cloud.dify.ai'
|
||||
|
||||
function fakeStore(fresh = false): CompatStore & { readonly marked: string[] } {
|
||||
const marked: string[] = []
|
||||
return {
|
||||
marked,
|
||||
isFreshCompatible: () => fresh,
|
||||
markCompatible: async (host) => {
|
||||
marked.push(host)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const server = (version: string): ServerVersionResponse => ({ version, edition: 'SELF_HOSTED' })
|
||||
|
||||
describe('enforceDifyVersion', () => {
|
||||
it('throws version_skew (exit 6) when the server is too old, and never caches it', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server('1.5.0'))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).rejects.toMatchObject({ code: ErrorCode.VersionSkew })
|
||||
expect(store.marked).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('passes and caches when the server is compatible', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server('1.6.4'))
|
||||
|
||||
const res = await enforceDifyVersion(HOST, { store, probe })
|
||||
|
||||
expect(res?.version).toBe('1.6.4')
|
||||
expect(store.marked).toEqual([HOST])
|
||||
})
|
||||
|
||||
it('passes (soft, no throw) and caches when the server is too new', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server('99.0.0'))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeDefined()
|
||||
expect(store.marked).toEqual([HOST])
|
||||
})
|
||||
|
||||
it('skips the probe entirely when the host is fresh-compatible', async () => {
|
||||
const store = fakeStore(true)
|
||||
const probe = vi.fn(async () => server('1.5.0')) // would throw if it ran
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeUndefined()
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-probes despite a fresh cache when forceFresh is set', async () => {
|
||||
const store = fakeStore(true)
|
||||
const probe = vi.fn(async () => server('1.5.0'))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe, forceFresh: true }))
|
||||
.rejects
|
||||
.toMatchObject({ code: ErrorCode.VersionSkew })
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails open (never blocks, never caches) when the probe errors', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => {
|
||||
throw new Error('net down')
|
||||
})
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeUndefined()
|
||||
expect(store.marked).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not block or cache on an unknown server version', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server(''))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeDefined()
|
||||
expect(store.marked).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@ -1,69 +0,0 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { CompatStore } from '@/cache/compat-store'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
|
||||
import { loadCompatStore } from '@/cache/compat-store'
|
||||
import { newError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { createHttpClient } from '@/http/client'
|
||||
import { openAPIBase } from '@/util/host'
|
||||
import { difyCompat, evaluateCompat } from './compat'
|
||||
import { versionInfo } from './info'
|
||||
|
||||
export type ServerVersionProbe = (host: string) => Promise<ServerVersionResponse>
|
||||
|
||||
const UPGRADE_HINT
|
||||
= `upgrade the Dify server to >= ${difyCompat.minDify} `
|
||||
+ '(https://docs.dify.ai/en/getting-started/install-self-hosted)'
|
||||
|
||||
// /_version is unauthenticated; same timeout/no-retry budget as the auto-nudge probe.
|
||||
const defaultProbe: ServerVersionProbe = async (host) => {
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
return new MetaClient(http).serverVersion()
|
||||
}
|
||||
|
||||
export type EnforceOptions = {
|
||||
readonly probe?: ServerVersionProbe
|
||||
readonly store?: CompatStore
|
||||
readonly forceFresh?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard version gate for the client → server direction: refuse a Dify server older
|
||||
* than this difyctl requires (its removed paths would only 404 otherwise).
|
||||
*
|
||||
* Cached: a host recently confirmed compatible is not re-probed for COMPAT_TTL_MS.
|
||||
* Only "compatible" is cached, so a just-upgraded server clears a previous block at
|
||||
* once. Fails open on any probe error — a flaky network never blocks a command.
|
||||
* Returns the probed server version when it actually probed (skipped/failed → undefined),
|
||||
* so the caller can reuse it.
|
||||
*/
|
||||
export async function enforceDifyVersion(
|
||||
host: string,
|
||||
opts: EnforceOptions = {},
|
||||
): Promise<ServerVersionResponse | undefined> {
|
||||
const store = opts.store ?? await loadCompatStore()
|
||||
if (opts.forceFresh !== true && store.isFreshCompatible(host))
|
||||
return undefined
|
||||
|
||||
const probe = opts.probe ?? defaultProbe
|
||||
let server: ServerVersionResponse
|
||||
try {
|
||||
server = await probe(host)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const verdict = evaluateCompat(server.version)
|
||||
if (verdict.status === 'too_old') {
|
||||
throw newError(
|
||||
ErrorCode.VersionSkew,
|
||||
`Dify server ${server.version} is too old for difyctl ${versionInfo.version}: ${verdict.detail}`,
|
||||
).withHint(UPGRADE_HINT)
|
||||
}
|
||||
|
||||
if (verdict.status === 'compatible' || verdict.status === 'too_new')
|
||||
await store.markCompatible(host)
|
||||
|
||||
return server
|
||||
}
|
||||
@ -44,9 +44,7 @@ export async function maybeNudgeCompat(host: string, deps: NudgeDeps): Promise<v
|
||||
}
|
||||
|
||||
const verdict = evaluateCompat(server.version)
|
||||
// Only "too new" is a soft nudge here; "too old" is hard-failed up front by
|
||||
// enforceDifyVersion, so the command never reaches this path for it.
|
||||
if (verdict.status !== 'too_new')
|
||||
if (verdict.status !== 'unsupported')
|
||||
return
|
||||
|
||||
deps.emit(formatBanner(deps.clientVersion, server.version, deps.color === true))
|
||||
|
||||
@ -105,7 +105,7 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.status).toBe('compatible')
|
||||
})
|
||||
|
||||
it('returns too_new when server version is above range', async () => {
|
||||
it('returns unsupported when server version is out of range', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
@ -113,7 +113,7 @@ describe('runVersionProbe', () => {
|
||||
})
|
||||
|
||||
expect(report.server.reachable).toBe(true)
|
||||
expect(report.compat.status).toBe('too_new')
|
||||
expect(report.compat.status).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('returns unknown when server returns an empty version string', async () => {
|
||||
|
||||
@ -131,7 +131,7 @@ describe('renderVersionText', () => {
|
||||
compat: {
|
||||
minDify: '1.6.0',
|
||||
maxDify: '1.7.0',
|
||||
status: 'too_new',
|
||||
status: 'unsupported',
|
||||
detail: 'server 99.0.0 outside [1.6.0, 1.7.0]',
|
||||
},
|
||||
}
|
||||
@ -175,7 +175,7 @@ describe('renderVersionText', () => {
|
||||
compat: {
|
||||
minDify: '1.6.0',
|
||||
maxDify: '1.7.0',
|
||||
status: 'too_new',
|
||||
status: 'unsupported',
|
||||
detail: 'server 99.0.0 outside [1.6.0, 1.7.0]',
|
||||
},
|
||||
}
|
||||
|
||||
@ -15,8 +15,7 @@ export type RenderOptions = {
|
||||
|
||||
const COMPAT_LABEL: Record<VersionReport['compat']['status'], string> = {
|
||||
compatible: 'ok',
|
||||
too_old: 'incompatible (server too old)',
|
||||
too_new: 'incompatible (server too new)',
|
||||
unsupported: 'incompatible',
|
||||
unknown: 'unknown',
|
||||
}
|
||||
|
||||
@ -51,8 +50,7 @@ export function renderVersionText(report: VersionReport, opts: RenderOptions = {
|
||||
lines.push('')
|
||||
|
||||
const verdictText = `Compatibility: ${COMPAT_LABEL[compat.status]} — ${compat.detail}`
|
||||
const incompatible = compat.status === 'too_old' || compat.status === 'too_new'
|
||||
lines.push(incompatible ? c.yellow(verdictText) : verdictText)
|
||||
lines.push(compat.status === 'unsupported' ? c.yellow(verdictText) : verdictText)
|
||||
|
||||
if (client.channel !== 'stable') {
|
||||
lines.push('')
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Single App Query (22 cases)
|
||||
*
|
||||
* Note: difyctl get app <id> queries a single app via GET /apps/<id>?fields=info.
|
||||
* Note: difyctl get app <id> queries a single app via GET /apps/<id>/describe?fields=info.
|
||||
* The response is returned in list-envelope format {page,limit,total,data:[...]}.
|
||||
*/
|
||||
|
||||
|
||||
24
cli/test/fixtures/dify-mock/server.test.ts
vendored
24
cli/test/fixtures/dify-mock/server.test.ts
vendored
@ -111,15 +111,15 @@ describe('dify-mock fixture server', () => {
|
||||
expect(body.data.map(r => r.id).sort()).toEqual(['app-3', 'app-4'])
|
||||
})
|
||||
|
||||
it('GET /openapi/v1/apps/:id returns 404 for unknown id', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/nope?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
|
||||
it('GET /openapi/v1/apps/:id/describe returns 404 for unknown id', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/nope/describe?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
|
||||
headers: { Authorization: 'Bearer dfoa_test' },
|
||||
})
|
||||
expect(r.status).toBe(404)
|
||||
})
|
||||
|
||||
it('GET /openapi/v1/apps/:id returns the app for known id', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
|
||||
it('GET /openapi/v1/apps/:id/describe returns the app for known id', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/describe?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
|
||||
headers: { Authorization: 'Bearer dfoa_test' },
|
||||
})
|
||||
expect(r.status).toBe(200)
|
||||
@ -127,8 +127,8 @@ describe('dify-mock fixture server', () => {
|
||||
expect(body.info.id).toBe('app-1')
|
||||
})
|
||||
|
||||
it('POST /openapi/v1/apps/:id:run returns SSE stream for chat app', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1:run`, {
|
||||
it('POST /openapi/v1/apps/:id/run returns SSE stream for chat app', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/run`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer dfoa_test',
|
||||
@ -142,8 +142,8 @@ describe('dify-mock fixture server', () => {
|
||||
expect(text).toContain('"answer":"echo: "')
|
||||
})
|
||||
|
||||
it('POST /openapi/v1/apps/:id:run returns SSE stream for workflow app', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/app-2:run`, {
|
||||
it('POST /openapi/v1/apps/:id/run returns SSE stream for workflow app', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/app-2/run`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer dfoa_test',
|
||||
@ -157,8 +157,8 @@ describe('dify-mock fixture server', () => {
|
||||
expect(text).toContain('"workflow_finished"')
|
||||
})
|
||||
|
||||
it('GET /openapi/v1/apps/:id?fields=info returns slim payload', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000&fields=info`, {
|
||||
it('GET /openapi/v1/apps/:id/describe?fields=info returns slim payload', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/describe?workspace_id=550e8400-e29b-41d4-a716-446655440000&fields=info`, {
|
||||
headers: { Authorization: 'Bearer dfoa_test' },
|
||||
})
|
||||
expect(r.status).toBe(200)
|
||||
@ -168,8 +168,8 @@ describe('dify-mock fixture server', () => {
|
||||
expect(body.input_schema).toBeNull()
|
||||
})
|
||||
|
||||
it('GET /openapi/v1/apps/:id full returns parameters when present', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
|
||||
it('GET /openapi/v1/apps/:id/describe full returns parameters when present', async () => {
|
||||
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/describe?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
|
||||
headers: { Authorization: 'Bearer dfoa_test' },
|
||||
})
|
||||
expect(r.status).toBe(200)
|
||||
|
||||
25
cli/test/fixtures/dify-mock/server.ts
vendored
25
cli/test/fixtures/dify-mock/server.ts
vendored
@ -15,9 +15,9 @@ export type DifyMock = {
|
||||
scenario: Scenario
|
||||
setScenario: (s: Scenario) => void
|
||||
stop: () => Promise<void>
|
||||
/** Body of the most recent POST to /apps/:id:run */
|
||||
/** Body of the most recent POST to /apps/:id/run */
|
||||
lastRunBody: Record<string, unknown> | null
|
||||
/** Number of times POST /apps/:id/files was called */
|
||||
/** Number of times POST /apps/:id/files/upload was called */
|
||||
uploadCallCount: number
|
||||
/** Body of the most recent POST to /workspaces/:id/apps/imports */
|
||||
lastImportBody: Record<string, unknown> | null
|
||||
@ -251,7 +251,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/openapi/v1/apps/:id', (c) => {
|
||||
app.get('/openapi/v1/apps/:id/describe', (c) => {
|
||||
const id = c.req.param('id')
|
||||
const wsId = c.req.query('workspace_id')
|
||||
const fieldsRaw = c.req.query('fields') ?? ''
|
||||
@ -279,7 +279,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/openapi/v1/permitted-external-apps/:id', (c) => {
|
||||
app.get('/openapi/v1/permitted-external-apps/:id/describe', (c) => {
|
||||
const id = c.req.param('id')
|
||||
const fieldsRaw = c.req.query('fields') ?? ''
|
||||
const fields = fieldsRaw === '' ? [] : fieldsRaw.split(',').map(s => s.trim()).filter(s => s !== '')
|
||||
@ -307,7 +307,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/openapi/v1/apps/:id/dsl', (c) => {
|
||||
app.get('/openapi/v1/apps/:id/export', (c) => {
|
||||
const id = c.req.param('id')
|
||||
const found = APPS.find(a => a.id === id)
|
||||
if (found === undefined)
|
||||
@ -315,7 +315,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
return c.json({ data: DSL_YAML })
|
||||
})
|
||||
|
||||
app.get('/openapi/v1/apps/:id/dependencies:check', (c) => {
|
||||
app.get('/openapi/v1/apps/:id/check-dependencies', (c) => {
|
||||
const id = c.req.param('id')
|
||||
const found = APPS.find(a => a.id === id)
|
||||
if (found === undefined)
|
||||
@ -335,13 +335,12 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
|
||||
})
|
||||
|
||||
app.post('/openapi/v1/workspaces/:wsId/apps/imports/:importId:confirm', (c) => {
|
||||
app.post('/openapi/v1/workspaces/:wsId/apps/imports/:importId/confirm', (c) => {
|
||||
return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
|
||||
})
|
||||
|
||||
app.post('/openapi/v1/apps/:id:run', async (c) => {
|
||||
// Hono drops the param adjacent to the `:run` literal; recover the app id from the path.
|
||||
const id = c.req.path.replace(/^.*\/apps\//, '').replace(/:run$/, '')
|
||||
app.post('/openapi/v1/apps/:id/run', async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json() as { query?: string, inputs?: unknown }
|
||||
if (state !== undefined)
|
||||
state.lastRunBody = body as Record<string, unknown>
|
||||
@ -401,7 +400,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
||||
})
|
||||
|
||||
app.post('/openapi/v1/apps/:id/files', async (c) => {
|
||||
app.post('/openapi/v1/apps/:id/files/upload', async (c) => {
|
||||
if (state !== undefined)
|
||||
state.uploadCallCount++
|
||||
const form = await c.req.formData()
|
||||
@ -422,11 +421,11 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
)
|
||||
})
|
||||
|
||||
app.post('/openapi/v1/apps/:id/tasks/:taskId:stop', (c) => {
|
||||
app.post('/openapi/v1/apps/:id/tasks/:taskId/stop', (c) => {
|
||||
return c.json({ result: 'success' })
|
||||
})
|
||||
|
||||
app.post('/openapi/v1/apps/:id/human-input-forms/:formToken:submit', (c) => {
|
||||
app.post('/openapi/v1/apps/:id/form/human_input/:formToken', (c) => {
|
||||
return c.json({})
|
||||
})
|
||||
|
||||
|
||||
@ -12,16 +12,16 @@ import {
|
||||
zGetAccountResponse,
|
||||
zGetAccountSessionsQuery,
|
||||
zGetAccountSessionsResponse,
|
||||
zGetAppsByAppIdDependenciesCheckPath,
|
||||
zGetAppsByAppIdDependenciesCheckResponse,
|
||||
zGetAppsByAppIdDslPath,
|
||||
zGetAppsByAppIdDslQuery,
|
||||
zGetAppsByAppIdDslResponse,
|
||||
zGetAppsByAppIdHumanInputFormsByFormTokenPath,
|
||||
zGetAppsByAppIdHumanInputFormsByFormTokenResponse,
|
||||
zGetAppsByAppIdPath,
|
||||
zGetAppsByAppIdQuery,
|
||||
zGetAppsByAppIdResponse,
|
||||
zGetAppsByAppIdCheckDependenciesPath,
|
||||
zGetAppsByAppIdCheckDependenciesResponse,
|
||||
zGetAppsByAppIdDescribePath,
|
||||
zGetAppsByAppIdDescribeQuery,
|
||||
zGetAppsByAppIdDescribeResponse,
|
||||
zGetAppsByAppIdExportPath,
|
||||
zGetAppsByAppIdExportQuery,
|
||||
zGetAppsByAppIdExportResponse,
|
||||
zGetAppsByAppIdFormHumanInputByFormTokenPath,
|
||||
zGetAppsByAppIdFormHumanInputByFormTokenResponse,
|
||||
zGetAppsByAppIdTasksByTaskIdEventsPath,
|
||||
zGetAppsByAppIdTasksByTaskIdEventsQuery,
|
||||
zGetAppsByAppIdTasksByTaskIdEventsResponse,
|
||||
@ -30,9 +30,9 @@ import {
|
||||
zGetHealthResponse,
|
||||
zGetOauthDeviceLookupQuery,
|
||||
zGetOauthDeviceLookupResponse,
|
||||
zGetPermittedExternalAppsByAppIdPath,
|
||||
zGetPermittedExternalAppsByAppIdQuery,
|
||||
zGetPermittedExternalAppsByAppIdResponse,
|
||||
zGetPermittedExternalAppsByAppIdDescribePath,
|
||||
zGetPermittedExternalAppsByAppIdDescribeQuery,
|
||||
zGetPermittedExternalAppsByAppIdDescribeResponse,
|
||||
zGetPermittedExternalAppsQuery,
|
||||
zGetPermittedExternalAppsResponse,
|
||||
zGetVersionResponse,
|
||||
@ -42,14 +42,11 @@ import {
|
||||
zGetWorkspacesByWorkspaceIdPath,
|
||||
zGetWorkspacesByWorkspaceIdResponse,
|
||||
zGetWorkspacesResponse,
|
||||
zPatchWorkspacesByWorkspaceIdMembersByMemberIdBody,
|
||||
zPatchWorkspacesByWorkspaceIdMembersByMemberIdPath,
|
||||
zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse,
|
||||
zPostAppsByAppIdFilesPath,
|
||||
zPostAppsByAppIdFilesResponse,
|
||||
zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody,
|
||||
zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath,
|
||||
zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse,
|
||||
zPostAppsByAppIdFilesUploadPath,
|
||||
zPostAppsByAppIdFilesUploadResponse,
|
||||
zPostAppsByAppIdFormHumanInputByFormTokenBody,
|
||||
zPostAppsByAppIdFormHumanInputByFormTokenPath,
|
||||
zPostAppsByAppIdFormHumanInputByFormTokenResponse,
|
||||
zPostAppsByAppIdRunBody,
|
||||
zPostAppsByAppIdRunPath,
|
||||
zPostAppsByAppIdRunResponse,
|
||||
@ -73,6 +70,9 @@ import {
|
||||
zPostWorkspacesByWorkspaceIdMembersResponse,
|
||||
zPostWorkspacesByWorkspaceIdSwitchPath,
|
||||
zPostWorkspacesByWorkspaceIdSwitchResponse,
|
||||
zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody,
|
||||
zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath,
|
||||
zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
export const get = oc
|
||||
@ -168,36 +168,54 @@ export const get5 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdDependenciesCheck',
|
||||
path: '/apps/{app_id}/dependencies:check',
|
||||
operationId: 'getAppsByAppIdCheckDependencies',
|
||||
path: '/apps/{app_id}/check-dependencies',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByAppIdDependenciesCheckPath }))
|
||||
.output(zGetAppsByAppIdDependenciesCheckResponse)
|
||||
.input(z.object({ params: zGetAppsByAppIdCheckDependenciesPath }))
|
||||
.output(zGetAppsByAppIdCheckDependenciesResponse)
|
||||
|
||||
export const check = {
|
||||
export const checkDependencies = {
|
||||
get: get5,
|
||||
}
|
||||
|
||||
export const dependencies = {
|
||||
check,
|
||||
}
|
||||
|
||||
export const get6 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdDsl',
|
||||
path: '/apps/{app_id}/dsl',
|
||||
operationId: 'getAppsByAppIdDescribe',
|
||||
path: '/apps/{app_id}/describe',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByAppIdDslPath, query: zGetAppsByAppIdDslQuery.optional() }))
|
||||
.output(zGetAppsByAppIdDslResponse)
|
||||
.input(
|
||||
z.object({
|
||||
params: zGetAppsByAppIdDescribePath,
|
||||
query: zGetAppsByAppIdDescribeQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zGetAppsByAppIdDescribeResponse)
|
||||
|
||||
export const dsl = {
|
||||
export const describe = {
|
||||
get: get6,
|
||||
}
|
||||
|
||||
export const get7 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdExport',
|
||||
path: '/apps/{app_id}/export',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(
|
||||
z.object({ params: zGetAppsByAppIdExportPath, query: zGetAppsByAppIdExportQuery.optional() }),
|
||||
)
|
||||
.output(zGetAppsByAppIdExportResponse)
|
||||
|
||||
export const export_ = {
|
||||
get: get7,
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to use as an input variable when running the app
|
||||
*/
|
||||
@ -206,59 +224,78 @@ export const post = oc
|
||||
description: 'Upload a file to use as an input variable when running the app',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdFiles',
|
||||
path: '/apps/{app_id}/files',
|
||||
operationId: 'postAppsByAppIdFilesUpload',
|
||||
path: '/apps/{app_id}/files/upload',
|
||||
successStatus: 201,
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zPostAppsByAppIdFilesPath }))
|
||||
.output(zPostAppsByAppIdFilesResponse)
|
||||
.input(z.object({ params: zPostAppsByAppIdFilesUploadPath }))
|
||||
.output(zPostAppsByAppIdFilesUploadResponse)
|
||||
|
||||
export const files = {
|
||||
export const upload = {
|
||||
post,
|
||||
}
|
||||
|
||||
export const files = {
|
||||
upload,
|
||||
}
|
||||
|
||||
export const get8 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdFormHumanInputByFormToken',
|
||||
path: '/apps/{app_id}/form/human_input/{form_token}',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByAppIdFormHumanInputByFormTokenPath }))
|
||||
.output(zGetAppsByAppIdFormHumanInputByFormTokenResponse)
|
||||
|
||||
export const post2 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdHumanInputFormsByFormTokenSubmit',
|
||||
path: '/apps/{app_id}/human-input-forms/{form_token}:submit',
|
||||
operationId: 'postAppsByAppIdFormHumanInputByFormToken',
|
||||
path: '/apps/{app_id}/form/human_input/{form_token}',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody,
|
||||
params: zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath,
|
||||
body: zPostAppsByAppIdFormHumanInputByFormTokenBody,
|
||||
params: zPostAppsByAppIdFormHumanInputByFormTokenPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse)
|
||||
.output(zPostAppsByAppIdFormHumanInputByFormTokenResponse)
|
||||
|
||||
export const submit = {
|
||||
export const byFormToken = {
|
||||
get: get8,
|
||||
post: post2,
|
||||
}
|
||||
|
||||
export const get7 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdHumanInputFormsByFormToken',
|
||||
path: '/apps/{app_id}/human-input-forms/{form_token}',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByAppIdHumanInputFormsByFormTokenPath }))
|
||||
.output(zGetAppsByAppIdHumanInputFormsByFormTokenResponse)
|
||||
|
||||
export const byFormToken = {
|
||||
get: get7,
|
||||
submit,
|
||||
}
|
||||
|
||||
export const humanInputForms = {
|
||||
export const humanInput = {
|
||||
byFormToken,
|
||||
}
|
||||
|
||||
export const get8 = oc
|
||||
export const form = {
|
||||
humanInput,
|
||||
}
|
||||
|
||||
export const post3 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdRun',
|
||||
path: '/apps/{app_id}/run',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ body: zPostAppsByAppIdRunBody, params: zPostAppsByAppIdRunPath }))
|
||||
.output(zPostAppsByAppIdRunResponse)
|
||||
|
||||
export const run = {
|
||||
post: post3,
|
||||
}
|
||||
|
||||
export const get9 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -275,22 +312,22 @@ export const get8 = oc
|
||||
.output(zGetAppsByAppIdTasksByTaskIdEventsResponse)
|
||||
|
||||
export const events = {
|
||||
get: get8,
|
||||
get: get9,
|
||||
}
|
||||
|
||||
export const post3 = oc
|
||||
export const post4 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdTasksByTaskIdStop',
|
||||
path: '/apps/{app_id}/tasks/{task_id}:stop',
|
||||
path: '/apps/{app_id}/tasks/{task_id}/stop',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zPostAppsByAppIdTasksByTaskIdStopPath }))
|
||||
.output(zPostAppsByAppIdTasksByTaskIdStopResponse)
|
||||
|
||||
export const stop = {
|
||||
post: post3,
|
||||
post: post4,
|
||||
}
|
||||
|
||||
export const byTaskId = {
|
||||
@ -302,40 +339,14 @@ export const tasks = {
|
||||
byTaskId,
|
||||
}
|
||||
|
||||
export const post4 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdRun',
|
||||
path: '/apps/{app_id}:run',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ body: zPostAppsByAppIdRunBody, params: zPostAppsByAppIdRunPath }))
|
||||
.output(zPostAppsByAppIdRunResponse)
|
||||
|
||||
export const run = {
|
||||
post: post4,
|
||||
}
|
||||
|
||||
export const get9 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppId',
|
||||
path: '/apps/{app_id}',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByAppIdPath, query: zGetAppsByAppIdQuery.optional() }))
|
||||
.output(zGetAppsByAppIdResponse)
|
||||
|
||||
export const byAppId = {
|
||||
get: get9,
|
||||
dependencies,
|
||||
dsl,
|
||||
checkDependencies,
|
||||
describe,
|
||||
export: export_,
|
||||
files,
|
||||
humanInputForms,
|
||||
tasks,
|
||||
form,
|
||||
run,
|
||||
tasks,
|
||||
}
|
||||
|
||||
export const get10 = oc
|
||||
@ -445,20 +456,24 @@ export const get12 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getPermittedExternalAppsByAppId',
|
||||
path: '/permitted-external-apps/{app_id}',
|
||||
operationId: 'getPermittedExternalAppsByAppIdDescribe',
|
||||
path: '/permitted-external-apps/{app_id}/describe',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
params: zGetPermittedExternalAppsByAppIdPath,
|
||||
query: zGetPermittedExternalAppsByAppIdQuery.optional(),
|
||||
params: zGetPermittedExternalAppsByAppIdDescribePath,
|
||||
query: zGetPermittedExternalAppsByAppIdDescribeQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zGetPermittedExternalAppsByAppIdResponse)
|
||||
.output(zGetPermittedExternalAppsByAppIdDescribeResponse)
|
||||
|
||||
export const describe2 = {
|
||||
get: get12,
|
||||
}
|
||||
|
||||
export const byAppId2 = {
|
||||
get: get12,
|
||||
describe: describe2,
|
||||
}
|
||||
|
||||
export const get13 = oc
|
||||
@ -482,7 +497,7 @@ export const post9 = oc
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postWorkspacesByWorkspaceIdAppsImportsByImportIdConfirm',
|
||||
path: '/workspaces/{workspace_id}/apps/imports/{import_id}:confirm',
|
||||
path: '/workspaces/{workspace_id}/apps/imports/{import_id}/confirm',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath }))
|
||||
@ -521,6 +536,26 @@ export const apps2 = {
|
||||
imports,
|
||||
}
|
||||
|
||||
export const put = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PUT',
|
||||
operationId: 'putWorkspacesByWorkspaceIdMembersByMemberIdRole',
|
||||
path: '/workspaces/{workspace_id}/members/{member_id}/role',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody,
|
||||
params: zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath,
|
||||
}),
|
||||
)
|
||||
.output(zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse)
|
||||
|
||||
export const role = {
|
||||
put,
|
||||
}
|
||||
|
||||
export const delete3 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
@ -532,25 +567,9 @@ export const delete3 = oc
|
||||
.input(z.object({ params: zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath }))
|
||||
.output(zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse)
|
||||
|
||||
export const patch = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'patchWorkspacesByWorkspaceIdMembersByMemberId',
|
||||
path: '/workspaces/{workspace_id}/members/{member_id}',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPatchWorkspacesByWorkspaceIdMembersByMemberIdBody,
|
||||
params: zPatchWorkspacesByWorkspaceIdMembersByMemberIdPath,
|
||||
}),
|
||||
)
|
||||
.output(zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse)
|
||||
|
||||
export const byMemberId = {
|
||||
delete: delete3,
|
||||
patch,
|
||||
role,
|
||||
}
|
||||
|
||||
export const get14 = oc
|
||||
@ -597,7 +616,7 @@ export const post12 = oc
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postWorkspacesByWorkspaceIdSwitch',
|
||||
path: '/workspaces/{workspace_id}:switch',
|
||||
path: '/workspaces/{workspace_id}/switch',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zPostWorkspacesByWorkspaceIdSwitchPath }))
|
||||
|
||||
@ -346,7 +346,6 @@ export type OpenApiErrorCode
|
||||
| 'unknown'
|
||||
| 'unsupported_file_type'
|
||||
| 'unsupported_media_type'
|
||||
| 'upgrade_required'
|
||||
|
||||
export type Package = {
|
||||
plugin_unique_identifier: string
|
||||
@ -617,7 +616,30 @@ export type GetAppsResponses = {
|
||||
|
||||
export type GetAppsResponse = GetAppsResponses[keyof GetAppsResponses]
|
||||
|
||||
export type GetAppsByAppIdData = {
|
||||
export type GetAppsByAppIdCheckDependenciesData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/check-dependencies'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdCheckDependenciesErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdCheckDependenciesError
|
||||
= GetAppsByAppIdCheckDependenciesErrors[keyof GetAppsByAppIdCheckDependenciesErrors]
|
||||
|
||||
export type GetAppsByAppIdCheckDependenciesResponses = {
|
||||
200: CheckDependenciesResult
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdCheckDependenciesResponse
|
||||
= GetAppsByAppIdCheckDependenciesResponses[keyof GetAppsByAppIdCheckDependenciesResponses]
|
||||
|
||||
export type GetAppsByAppIdDescribeData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
@ -625,46 +647,25 @@ export type GetAppsByAppIdData = {
|
||||
query?: {
|
||||
fields?: string
|
||||
}
|
||||
url: '/apps/{app_id}'
|
||||
url: '/apps/{app_id}/describe'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdErrors = {
|
||||
export type GetAppsByAppIdDescribeErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdError = GetAppsByAppIdErrors[keyof GetAppsByAppIdErrors]
|
||||
export type GetAppsByAppIdDescribeError
|
||||
= GetAppsByAppIdDescribeErrors[keyof GetAppsByAppIdDescribeErrors]
|
||||
|
||||
export type GetAppsByAppIdResponses = {
|
||||
export type GetAppsByAppIdDescribeResponses = {
|
||||
200: AppDescribeResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdResponse = GetAppsByAppIdResponses[keyof GetAppsByAppIdResponses]
|
||||
export type GetAppsByAppIdDescribeResponse
|
||||
= GetAppsByAppIdDescribeResponses[keyof GetAppsByAppIdDescribeResponses]
|
||||
|
||||
export type GetAppsByAppIdDependenciesCheckData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/dependencies:check'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdDependenciesCheckErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdDependenciesCheckError
|
||||
= GetAppsByAppIdDependenciesCheckErrors[keyof GetAppsByAppIdDependenciesCheckErrors]
|
||||
|
||||
export type GetAppsByAppIdDependenciesCheckResponses = {
|
||||
200: CheckDependenciesResult
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdDependenciesCheckResponse
|
||||
= GetAppsByAppIdDependenciesCheckResponses[keyof GetAppsByAppIdDependenciesCheckResponses]
|
||||
|
||||
export type GetAppsByAppIdDslData = {
|
||||
export type GetAppsByAppIdExportData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
@ -673,32 +674,33 @@ export type GetAppsByAppIdDslData = {
|
||||
include_secret?: boolean
|
||||
workflow_id?: string
|
||||
}
|
||||
url: '/apps/{app_id}/dsl'
|
||||
url: '/apps/{app_id}/export'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdDslErrors = {
|
||||
export type GetAppsByAppIdExportErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdDslError = GetAppsByAppIdDslErrors[keyof GetAppsByAppIdDslErrors]
|
||||
export type GetAppsByAppIdExportError = GetAppsByAppIdExportErrors[keyof GetAppsByAppIdExportErrors]
|
||||
|
||||
export type GetAppsByAppIdDslResponses = {
|
||||
export type GetAppsByAppIdExportResponses = {
|
||||
200: AppDslExportResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdDslResponse = GetAppsByAppIdDslResponses[keyof GetAppsByAppIdDslResponses]
|
||||
export type GetAppsByAppIdExportResponse
|
||||
= GetAppsByAppIdExportResponses[keyof GetAppsByAppIdExportResponses]
|
||||
|
||||
export type PostAppsByAppIdFilesData = {
|
||||
export type PostAppsByAppIdFilesUploadData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/files'
|
||||
url: '/apps/{app_id}/files/upload'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdFilesErrors = {
|
||||
export type PostAppsByAppIdFilesUploadErrors = {
|
||||
400: unknown
|
||||
401: unknown
|
||||
413: unknown
|
||||
@ -706,56 +708,79 @@ export type PostAppsByAppIdFilesErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdFilesError = PostAppsByAppIdFilesErrors[keyof PostAppsByAppIdFilesErrors]
|
||||
export type PostAppsByAppIdFilesUploadError
|
||||
= PostAppsByAppIdFilesUploadErrors[keyof PostAppsByAppIdFilesUploadErrors]
|
||||
|
||||
export type PostAppsByAppIdFilesResponses = {
|
||||
export type PostAppsByAppIdFilesUploadResponses = {
|
||||
201: FileResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdFilesResponse
|
||||
= PostAppsByAppIdFilesResponses[keyof PostAppsByAppIdFilesResponses]
|
||||
export type PostAppsByAppIdFilesUploadResponse
|
||||
= PostAppsByAppIdFilesUploadResponses[keyof PostAppsByAppIdFilesUploadResponses]
|
||||
|
||||
export type GetAppsByAppIdHumanInputFormsByFormTokenData = {
|
||||
export type GetAppsByAppIdFormHumanInputByFormTokenData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
form_token: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/human-input-forms/{form_token}'
|
||||
url: '/apps/{app_id}/form/human_input/{form_token}'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdHumanInputFormsByFormTokenResponses = {
|
||||
export type GetAppsByAppIdFormHumanInputByFormTokenResponses = {
|
||||
200: HumanInputFormDefinitionResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdHumanInputFormsByFormTokenResponse
|
||||
= GetAppsByAppIdHumanInputFormsByFormTokenResponses[keyof GetAppsByAppIdHumanInputFormsByFormTokenResponses]
|
||||
export type GetAppsByAppIdFormHumanInputByFormTokenResponse
|
||||
= GetAppsByAppIdFormHumanInputByFormTokenResponses[keyof GetAppsByAppIdFormHumanInputByFormTokenResponses]
|
||||
|
||||
export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitData = {
|
||||
export type PostAppsByAppIdFormHumanInputByFormTokenData = {
|
||||
body: HumanInputFormSubmitPayload
|
||||
path: {
|
||||
app_id: string
|
||||
form_token: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/human-input-forms/{form_token}:submit'
|
||||
url: '/apps/{app_id}/form/human_input/{form_token}'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitErrors = {
|
||||
export type PostAppsByAppIdFormHumanInputByFormTokenErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitError
|
||||
= PostAppsByAppIdHumanInputFormsByFormTokenSubmitErrors[keyof PostAppsByAppIdHumanInputFormsByFormTokenSubmitErrors]
|
||||
export type PostAppsByAppIdFormHumanInputByFormTokenError
|
||||
= PostAppsByAppIdFormHumanInputByFormTokenErrors[keyof PostAppsByAppIdFormHumanInputByFormTokenErrors]
|
||||
|
||||
export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses = {
|
||||
export type PostAppsByAppIdFormHumanInputByFormTokenResponses = {
|
||||
200: FormSubmitResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse
|
||||
= PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses[keyof PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses]
|
||||
export type PostAppsByAppIdFormHumanInputByFormTokenResponse
|
||||
= PostAppsByAppIdFormHumanInputByFormTokenResponses[keyof PostAppsByAppIdFormHumanInputByFormTokenResponses]
|
||||
|
||||
export type PostAppsByAppIdRunData = {
|
||||
body: AppRunRequest
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/run'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdRunErrors = {
|
||||
422: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdRunError = PostAppsByAppIdRunErrors[keyof PostAppsByAppIdRunErrors]
|
||||
|
||||
export type PostAppsByAppIdRunResponses = {
|
||||
200: EventStreamResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdRunResponse
|
||||
= PostAppsByAppIdRunResponses[keyof PostAppsByAppIdRunResponses]
|
||||
|
||||
export type GetAppsByAppIdTasksByTaskIdEventsData = {
|
||||
body?: never
|
||||
@ -784,7 +809,7 @@ export type PostAppsByAppIdTasksByTaskIdStopData = {
|
||||
task_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/tasks/{task_id}:stop'
|
||||
url: '/apps/{app_id}/tasks/{task_id}/stop'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdTasksByTaskIdStopErrors = {
|
||||
@ -801,28 +826,6 @@ export type PostAppsByAppIdTasksByTaskIdStopResponses = {
|
||||
export type PostAppsByAppIdTasksByTaskIdStopResponse
|
||||
= PostAppsByAppIdTasksByTaskIdStopResponses[keyof PostAppsByAppIdTasksByTaskIdStopResponses]
|
||||
|
||||
export type PostAppsByAppIdRunData = {
|
||||
body: AppRunRequest
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}:run'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdRunErrors = {
|
||||
422: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdRunError = PostAppsByAppIdRunErrors[keyof PostAppsByAppIdRunErrors]
|
||||
|
||||
export type PostAppsByAppIdRunResponses = {
|
||||
200: EventStreamResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdRunResponse
|
||||
= PostAppsByAppIdRunResponses[keyof PostAppsByAppIdRunResponses]
|
||||
|
||||
export type PostOauthDeviceApproveData = {
|
||||
body: DeviceMutateRequest
|
||||
path?: never
|
||||
@ -922,7 +925,7 @@ export type GetPermittedExternalAppsResponses = {
|
||||
export type GetPermittedExternalAppsResponse
|
||||
= GetPermittedExternalAppsResponses[keyof GetPermittedExternalAppsResponses]
|
||||
|
||||
export type GetPermittedExternalAppsByAppIdData = {
|
||||
export type GetPermittedExternalAppsByAppIdDescribeData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
@ -930,23 +933,23 @@ export type GetPermittedExternalAppsByAppIdData = {
|
||||
query?: {
|
||||
fields?: string
|
||||
}
|
||||
url: '/permitted-external-apps/{app_id}'
|
||||
url: '/permitted-external-apps/{app_id}/describe'
|
||||
}
|
||||
|
||||
export type GetPermittedExternalAppsByAppIdErrors = {
|
||||
export type GetPermittedExternalAppsByAppIdDescribeErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetPermittedExternalAppsByAppIdError
|
||||
= GetPermittedExternalAppsByAppIdErrors[keyof GetPermittedExternalAppsByAppIdErrors]
|
||||
export type GetPermittedExternalAppsByAppIdDescribeError
|
||||
= GetPermittedExternalAppsByAppIdDescribeErrors[keyof GetPermittedExternalAppsByAppIdDescribeErrors]
|
||||
|
||||
export type GetPermittedExternalAppsByAppIdResponses = {
|
||||
export type GetPermittedExternalAppsByAppIdDescribeResponses = {
|
||||
200: AppDescribeResponse
|
||||
}
|
||||
|
||||
export type GetPermittedExternalAppsByAppIdResponse
|
||||
= GetPermittedExternalAppsByAppIdResponses[keyof GetPermittedExternalAppsByAppIdResponses]
|
||||
export type GetPermittedExternalAppsByAppIdDescribeResponse
|
||||
= GetPermittedExternalAppsByAppIdDescribeResponses[keyof GetPermittedExternalAppsByAppIdDescribeResponses]
|
||||
|
||||
export type GetWorkspacesData = {
|
||||
body?: never
|
||||
@ -1023,7 +1026,7 @@ export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmData = {
|
||||
workspace_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/{workspace_id}/apps/imports/{import_id}:confirm'
|
||||
url: '/workspaces/{workspace_id}/apps/imports/{import_id}/confirm'
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors = {
|
||||
@ -1116,30 +1119,30 @@ export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses = {
|
||||
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse
|
||||
= DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses]
|
||||
|
||||
export type PatchWorkspacesByWorkspaceIdMembersByMemberIdData = {
|
||||
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleData = {
|
||||
body: MemberRoleUpdatePayload
|
||||
path: {
|
||||
member_id: string
|
||||
workspace_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/{workspace_id}/members/{member_id}'
|
||||
url: '/workspaces/{workspace_id}/members/{member_id}/role'
|
||||
}
|
||||
|
||||
export type PatchWorkspacesByWorkspaceIdMembersByMemberIdErrors = {
|
||||
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PatchWorkspacesByWorkspaceIdMembersByMemberIdError
|
||||
= PatchWorkspacesByWorkspaceIdMembersByMemberIdErrors[keyof PatchWorkspacesByWorkspaceIdMembersByMemberIdErrors]
|
||||
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleError
|
||||
= PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors[keyof PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors]
|
||||
|
||||
export type PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses = {
|
||||
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses = {
|
||||
200: MemberActionResponse
|
||||
}
|
||||
|
||||
export type PatchWorkspacesByWorkspaceIdMembersByMemberIdResponse
|
||||
= PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses[keyof PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses]
|
||||
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse
|
||||
= PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses[keyof PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses]
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdSwitchData = {
|
||||
body?: never
|
||||
@ -1147,7 +1150,7 @@ export type PostWorkspacesByWorkspaceIdSwitchData = {
|
||||
workspace_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/{workspace_id}:switch'
|
||||
url: '/workspaces/{workspace_id}/switch'
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdSwitchErrors = {
|
||||
|
||||
@ -27,7 +27,7 @@ export const zAppDescribeInfo = z.object({
|
||||
/**
|
||||
* AppDescribeQuery
|
||||
*
|
||||
* `?fields=` allow-list for GET /apps/<id>.
|
||||
* `?fields=` allow-list for GET /apps/<id>/describe.
|
||||
*
|
||||
* Empty / omitted → all blocks. Unknown member → ValidationError → 422.
|
||||
*/
|
||||
@ -47,7 +47,7 @@ export const zAppDescribeResponse = z.object({
|
||||
/**
|
||||
* AppDslExportQuery
|
||||
*
|
||||
* Query parameters for GET /apps/<app_id>/dsl.
|
||||
* Query parameters for GET /apps/<app_id>/export.
|
||||
*/
|
||||
export const zAppDslExportQuery = z.object({
|
||||
include_secret: z.boolean().optional().default(false),
|
||||
@ -254,7 +254,7 @@ export const zFileResponse = z.object({
|
||||
/**
|
||||
* FormSubmitResponse
|
||||
*
|
||||
* Empty 200 body for POST /apps/<id>/human-input-forms/<token>:submit. `extra='forbid'`
|
||||
* Empty 200 body for POST /apps/<id>/form/human_input/<token>. `extra='forbid'`
|
||||
* pins `additionalProperties: false` so the generated contract is an exact `{}` rather
|
||||
* than an under-annotated open object.
|
||||
*/
|
||||
@ -429,7 +429,6 @@ export const zOpenApiErrorCode = z.enum([
|
||||
'unknown',
|
||||
'unsupported_file_type',
|
||||
'unsupported_media_type',
|
||||
'upgrade_required',
|
||||
])
|
||||
|
||||
/**
|
||||
@ -560,7 +559,7 @@ export const zPermittedExternalAppsListQuery = z.object({
|
||||
/**
|
||||
* TaskStopResponse
|
||||
*
|
||||
* 200 body for POST /apps/<id>/tasks/<task_id>:stop. The handler always returns
|
||||
* 200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns
|
||||
* {"result": "success"}, so `result` is required (no default) — the generated contract
|
||||
* types it as a required `'success'` rather than an optional field.
|
||||
*/
|
||||
@ -740,33 +739,33 @@ export const zGetAppsQuery = z.object({
|
||||
*/
|
||||
export const zGetAppsResponse = zAppListResponse
|
||||
|
||||
export const zGetAppsByAppIdPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAppsByAppIdQuery = z.object({
|
||||
fields: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* App description
|
||||
*/
|
||||
export const zGetAppsByAppIdResponse = zAppDescribeResponse
|
||||
|
||||
export const zGetAppsByAppIdDependenciesCheckPath = z.object({
|
||||
export const zGetAppsByAppIdCheckDependenciesPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Dependencies checked
|
||||
*/
|
||||
export const zGetAppsByAppIdDependenciesCheckResponse = zCheckDependenciesResult
|
||||
export const zGetAppsByAppIdCheckDependenciesResponse = zCheckDependenciesResult
|
||||
|
||||
export const zGetAppsByAppIdDslPath = z.object({
|
||||
export const zGetAppsByAppIdDescribePath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAppsByAppIdDslQuery = z.object({
|
||||
export const zGetAppsByAppIdDescribeQuery = z.object({
|
||||
fields: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* App description
|
||||
*/
|
||||
export const zGetAppsByAppIdDescribeResponse = zAppDescribeResponse
|
||||
|
||||
export const zGetAppsByAppIdExportPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAppsByAppIdExportQuery = z.object({
|
||||
include_secret: z.boolean().optional().default(false),
|
||||
workflow_id: z.string().optional(),
|
||||
})
|
||||
@ -774,18 +773,18 @@ export const zGetAppsByAppIdDslQuery = z.object({
|
||||
/**
|
||||
* Export successful
|
||||
*/
|
||||
export const zGetAppsByAppIdDslResponse = zAppDslExportResponse
|
||||
export const zGetAppsByAppIdExportResponse = zAppDslExportResponse
|
||||
|
||||
export const zPostAppsByAppIdFilesPath = z.object({
|
||||
export const zPostAppsByAppIdFilesUploadPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* File uploaded successfully
|
||||
*/
|
||||
export const zPostAppsByAppIdFilesResponse = zFileResponse
|
||||
export const zPostAppsByAppIdFilesUploadResponse = zFileResponse
|
||||
|
||||
export const zGetAppsByAppIdHumanInputFormsByFormTokenPath = z.object({
|
||||
export const zGetAppsByAppIdFormHumanInputByFormTokenPath = z.object({
|
||||
app_id: z.string(),
|
||||
form_token: z.string(),
|
||||
})
|
||||
@ -793,11 +792,11 @@ export const zGetAppsByAppIdHumanInputFormsByFormTokenPath = z.object({
|
||||
/**
|
||||
* Form definition
|
||||
*/
|
||||
export const zGetAppsByAppIdHumanInputFormsByFormTokenResponse = zHumanInputFormDefinitionResponse
|
||||
export const zGetAppsByAppIdFormHumanInputByFormTokenResponse = zHumanInputFormDefinitionResponse
|
||||
|
||||
export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody = zHumanInputFormSubmitPayload
|
||||
export const zPostAppsByAppIdFormHumanInputByFormTokenBody = zHumanInputFormSubmitPayload
|
||||
|
||||
export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath = z.object({
|
||||
export const zPostAppsByAppIdFormHumanInputByFormTokenPath = z.object({
|
||||
app_id: z.string(),
|
||||
form_token: z.string(),
|
||||
})
|
||||
@ -805,7 +804,18 @@ export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath = z.object({
|
||||
/**
|
||||
* Form submitted
|
||||
*/
|
||||
export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse = zFormSubmitResponse
|
||||
export const zPostAppsByAppIdFormHumanInputByFormTokenResponse = zFormSubmitResponse
|
||||
|
||||
export const zPostAppsByAppIdRunBody = zAppRunRequest
|
||||
|
||||
export const zPostAppsByAppIdRunPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Run result (SSE stream)
|
||||
*/
|
||||
export const zPostAppsByAppIdRunResponse = zEventStreamResponse
|
||||
|
||||
export const zGetAppsByAppIdTasksByTaskIdEventsPath = z.object({
|
||||
app_id: z.string(),
|
||||
@ -832,17 +842,6 @@ export const zPostAppsByAppIdTasksByTaskIdStopPath = z.object({
|
||||
*/
|
||||
export const zPostAppsByAppIdTasksByTaskIdStopResponse = zTaskStopResponse
|
||||
|
||||
export const zPostAppsByAppIdRunBody = zAppRunRequest
|
||||
|
||||
export const zPostAppsByAppIdRunPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Run result (SSE stream)
|
||||
*/
|
||||
export const zPostAppsByAppIdRunResponse = zEventStreamResponse
|
||||
|
||||
export const zPostOauthDeviceApproveBody = zDeviceMutateRequest
|
||||
|
||||
/**
|
||||
@ -892,18 +891,18 @@ export const zGetPermittedExternalAppsQuery = z.object({
|
||||
*/
|
||||
export const zGetPermittedExternalAppsResponse = zPermittedExternalAppsListResponse
|
||||
|
||||
export const zGetPermittedExternalAppsByAppIdPath = z.object({
|
||||
export const zGetPermittedExternalAppsByAppIdDescribePath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetPermittedExternalAppsByAppIdQuery = z.object({
|
||||
export const zGetPermittedExternalAppsByAppIdDescribeQuery = z.object({
|
||||
fields: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Permitted external app description
|
||||
*/
|
||||
export const zGetPermittedExternalAppsByAppIdResponse = zAppDescribeResponse
|
||||
export const zGetPermittedExternalAppsByAppIdDescribeResponse = zAppDescribeResponse
|
||||
|
||||
/**
|
||||
* Workspace list
|
||||
@ -975,9 +974,9 @@ export const zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath = z.object({
|
||||
*/
|
||||
export const zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse = zMemberActionResponse
|
||||
|
||||
export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdBody = zMemberRoleUpdatePayload
|
||||
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody = zMemberRoleUpdatePayload
|
||||
|
||||
export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdPath = z.object({
|
||||
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath = z.object({
|
||||
member_id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
})
|
||||
@ -985,7 +984,7 @@ export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdPath = z.object({
|
||||
/**
|
||||
* Role updated
|
||||
*/
|
||||
export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse = zMemberActionResponse
|
||||
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse = zMemberActionResponse
|
||||
|
||||
export const zPostWorkspacesByWorkspaceIdSwitchPath = z.object({
|
||||
workspace_id: z.string(),
|
||||
|
||||
@ -102,11 +102,11 @@ const segmentWords = (segment: string) => {
|
||||
return toWords(segment)
|
||||
}
|
||||
|
||||
// Split on `:` too so custom methods nest as their own node (apps.byAppId.run), not apps.appIdRun.
|
||||
const routeNamingSegments = (routePath: string) => routePath.split(/[/:]/).filter(Boolean)
|
||||
|
||||
const routeWords = (routePath: string) => {
|
||||
return routeNamingSegments(routePath).flatMap(segmentWords)
|
||||
return routePath
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.flatMap(segmentWords)
|
||||
}
|
||||
|
||||
const operationId = (method: string, routePath: string) => {
|
||||
@ -114,7 +114,10 @@ const operationId = (method: string, routePath: string) => {
|
||||
}
|
||||
|
||||
const contractPathSegments = (operation: ApiContractOperation) => {
|
||||
const segments = routeNamingSegments(operation.path).map(segment => toCamelCase(segmentWords(segment)))
|
||||
const segments = operation.path
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map(segment => toCamelCase(segmentWords(segment)))
|
||||
|
||||
return [...(segments.length > 0 ? segments : ['root']), operation.method.toLowerCase()]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user