diff --git a/api/controllers/common/fields.py b/api/controllers/common/fields.py index 6a0b35aa633f1c..7f16958090a58a 100644 --- a/api/controllers/common/fields.py +++ b/api/controllers/common/fields.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, RootModel, computed_field @@ -8,7 +8,6 @@ from graphon.file import helpers as file_helpers from models.model import IconType -type JSONValue = str | int | float | bool | None | dict[str, Any] | list[Any] type JSONObject = dict[str, Any] @@ -24,10 +23,6 @@ class SimpleResultResponse(ResponseModel): result: str -class GeneratedAppResponse(RootModel[JSONValue]): - root: JSONValue - - class EventStreamResponse(RootModel[str]): root: str @@ -52,6 +47,11 @@ class AudioTranscriptResponse(ResponseModel): text: str +class ValidationResultResponse(ResponseModel): + result: Literal["success", "error"] + error: str | None = None + + class SimpleResultMessageResponse(ResponseModel): result: str message: str diff --git a/api/controllers/console/app/agent_app_feature.py b/api/controllers/console/app/agent_app_feature.py index 358e552beb0cc0..305dd01feec571 100644 --- a/api/controllers/console/app/agent_app_feature.py +++ b/api/controllers/console/app/agent_app_feature.py @@ -30,7 +30,6 @@ ) from events.app_event import app_model_config_was_updated from extensions.ext_database import db -from libs.helper import dump_response from libs.login import login_required from models import Account from models.agent_config_entities import ( @@ -99,4 +98,4 @@ def post(self, tenant_id: str, current_user: Account, agent_id: UUID): app_model_config_was_updated.send(app_model, app_model_config=new_app_model_config) - return dump_response(SimpleResultResponse, {"result": "success"}) + return SimpleResultResponse(result="success").model_dump(mode="json") diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index 48fb4aedc6346d..adcf9a6a802478 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -1,7 +1,7 @@ from typing import Any, Literal from uuid import UUID -from flask import abort, make_response, request +from flask import abort, request from flask_restx import Resource from pydantic import BaseModel, Field, TypeAdapter, field_validator @@ -26,10 +26,12 @@ AnnotationExportList, AnnotationHitHistory, AnnotationHitHistoryList, + AnnotationJobStatusDetailResponse, + AnnotationJobStatusResponse, AnnotationList, ) from fields.base import ResponseModel -from libs.helper import uuid_value +from libs.helper import dump_response, uuid_value from libs.login import login_required from services.annotation_service import ( AppAnnotationService, @@ -99,23 +101,23 @@ def validate_message_id(cls, value: str) -> str: return uuid_value(value) -class AnnotationJobStatusResponse(ResponseModel): - job_id: str | None = None - job_status: str | None = None - error_msg: str | None = None - record_count: int | None = None - - -class AnnotationEmbeddingModelResponse(ResponseModel): +class AnnotationSettingEmbeddingModelResponse(ResponseModel): embedding_provider_name: str | None = None embedding_model_name: str | None = None class AnnotationSettingResponse(ResponseModel): - id: str | None = None enabled: bool + id: str | None = None score_threshold: float | None = None - embedding_model: AnnotationEmbeddingModelResponse | None = None + embedding_model: AnnotationSettingEmbeddingModelResponse | None = None + + +class AnnotationBatchImportResponse(ResponseModel): + job_id: str | None = None + job_status: str | None = None + record_count: int | None = None + error_msg: str | None = None register_schema_models( @@ -142,7 +144,10 @@ class AnnotationSettingResponse(ResponseModel): AnnotationHitHistory, AnnotationHitHistoryList, AnnotationJobStatusResponse, + AnnotationJobStatusDetailResponse, + AnnotationSettingEmbeddingModelResponse, AnnotationSettingResponse, + AnnotationBatchImportResponse, ) @@ -172,7 +177,7 @@ def post(self, app_id: UUID, action: Literal["enable", "disable"]): result = AppAnnotationService.enable_app_annotation(enable_args, str(app_id)) case "disable": result = AppAnnotationService.disable_app_annotation(str(app_id)) - return result, 200 + return dump_response(AnnotationJobStatusResponse, result), 200 @console_ns.route("/apps//annotation-setting") @@ -193,7 +198,7 @@ class AppAnnotationSettingDetailApi(Resource): @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_id: UUID): result = AppAnnotationService.get_app_annotation_setting_by_app_id(str(app_id)) - return result, 200 + return dump_response(AnnotationSettingResponse, result), 200 @console_ns.route("/apps//annotation-settings/") @@ -218,7 +223,7 @@ def post(self, app_id: UUID, annotation_setting_id: UUID): result = AppAnnotationService.update_app_annotation_setting( str(app_id), annotation_setting_id_str, setting_args ) - return result, 200 + return dump_response(AnnotationSettingResponse, result), 200 @console_ns.route("/apps//annotation-reply//status/") @@ -227,9 +232,7 @@ class AnnotationReplyActionStatusApi(Resource): @console_ns.doc(description="Get status of annotation reply action job") @console_ns.doc(params={"app_id": "Application ID", "job_id": "Job ID", "action": "Action type"}) @console_ns.response( - 200, - "Job status retrieved successfully", - console_ns.models[AnnotationJobStatusResponse.__name__], + 200, "Job status retrieved successfully", console_ns.models[AnnotationJobStatusDetailResponse.__name__] ) @console_ns.response(403, "Insufficient permissions") @setup_required @@ -251,7 +254,9 @@ def get(self, app_id: UUID, job_id: UUID, action: str): app_annotation_error_key = f"{action}_app_annotation_error_{job_id_str}" error_msg = redis_client.get(app_annotation_error_key).decode() - return {"job_id": job_id_str, "job_status": job_status, "error_msg": error_msg}, 200 + return AnnotationJobStatusDetailResponse( + job_id=job_id_str, job_status=job_status, error_msg=error_msg + ).model_dump(mode="json"), 200 @console_ns.route("/apps//annotations") @@ -275,14 +280,9 @@ def get(self, app_id: UUID): annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(str(app_id), page, limit, keyword) annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True) - response = AnnotationList( - data=annotation_models, - has_more=len(annotation_list) == limit, - limit=limit, - total=total, - page=page, - ) - return response.model_dump(mode="json"), 200 + return AnnotationList( + data=annotation_models, has_more=len(annotation_list) == limit, limit=limit, total=total, page=page + ).model_dump(mode="json"), 200 @console_ns.doc("create_annotation") @console_ns.doc(description="Create a new annotation for an app") @@ -308,7 +308,7 @@ def post(self, app_id: UUID): if args.question is not None: upsert_args["question"] = args.question annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, str(app_id)) - return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") + return dump_response(Annotation, annotation), 201 @setup_required @login_required @@ -357,14 +357,14 @@ class AnnotationExportApi(Resource): def get(self, app_id: UUID): annotation_list = AppAnnotationService.export_annotation_list_by_app_id(str(app_id)) annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True) - response_data = AnnotationExportList(data=annotation_models).model_dump(mode="json") - - # Create response with secure headers for CSV export - response = make_response(response_data, 200) - response.headers["Content-Type"] = "application/json; charset=utf-8" - response.headers["X-Content-Type-Options"] = "nosniff" - - return response + return ( + AnnotationExportList(data=annotation_models).model_dump(mode="json"), + 200, + { + "Content-Type": "application/json; charset=utf-8", + "X-Content-Type-Options": "nosniff", + }, + ) @console_ns.route("/apps//annotations/") @@ -392,7 +392,7 @@ def post(self, app_id: UUID, annotation_id: UUID): annotation = AppAnnotationService.update_app_annotation_directly( update_args, str(app_id), str(annotation_id), db.session ) - return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") + return dump_response(Annotation, annotation) @setup_required @login_required @@ -411,9 +411,7 @@ class AnnotationBatchImportApi(Resource): @console_ns.doc(description="Batch import annotations from CSV file with rate limiting and security checks") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.response( - 200, - "Batch import started successfully", - console_ns.models[AnnotationJobStatusResponse.__name__], + 200, "Batch import started successfully", console_ns.models[AnnotationBatchImportResponse.__name__] ) @console_ns.response(403, "Insufficient permissions") @console_ns.response(400, "No file uploaded or too many files") @@ -460,7 +458,10 @@ def post(self, app_id: UUID): if file_size == 0: raise ValueError("The uploaded file is empty") - return AppAnnotationService.batch_import_app_annotations(str(app_id), file) + return dump_response( + AnnotationBatchImportResponse, + AppAnnotationService.batch_import_app_annotations(str(app_id), file), + ) @console_ns.route("/apps//annotations/batch-import-status/") @@ -469,9 +470,7 @@ class AnnotationBatchImportStatusApi(Resource): @console_ns.doc(description="Get status of batch import job") @console_ns.doc(params={"app_id": "Application ID", "job_id": "Job ID"}) @console_ns.response( - 200, - "Job status retrieved successfully", - console_ns.models[AnnotationJobStatusResponse.__name__], + 200, "Job status retrieved successfully", console_ns.models[AnnotationJobStatusDetailResponse.__name__] ) @console_ns.response(403, "Insufficient permissions") @setup_required @@ -491,7 +490,9 @@ def get(self, app_id: UUID, job_id: UUID): indexing_error_msg_key = f"app_annotation_batch_import_error_msg_{str(job_id)}" error_msg = redis_client.get(indexing_error_msg_key).decode() - return {"job_id": job_id, "job_status": job_status, "error_msg": error_msg}, 200 + return AnnotationJobStatusDetailResponse( + job_id=str(job_id), job_status=job_status, error_msg=error_msg + ).model_dump(mode="json"), 200 @console_ns.route("/apps//annotations//hit-histories") @@ -520,11 +521,6 @@ def get(self, app_id: UUID, annotation_id: UUID): history_models = TypeAdapter(list[AnnotationHitHistory]).validate_python( annotation_hit_history_list, from_attributes=True ) - response = AnnotationHitHistoryList( - data=history_models, - has_more=len(annotation_hit_history_list) == limit, - limit=limit, - total=total, - page=page, - ) - return response.model_dump(mode="json") + return AnnotationHitHistoryList( + data=history_models, has_more=len(annotation_hit_history_list) == limit, limit=limit, total=total, page=page + ).model_dump(mode="json") diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index ff1eeeb890710e..da7034de62f1bb 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -230,13 +230,10 @@ def validate_tracing_provider(cls, value: str | None, info) -> str | None: class AppTraceResponse(ResponseModel): - enabled: bool + enabled: bool = False tracing_provider: str | None = None -type JSONValue = Any - - class Tag(ResponseModel): id: str name: str @@ -257,7 +254,7 @@ def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: class ModelConfigPartial(ResponseModel): - model: JSONValue | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model")) + model: Any | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model")) pre_prompt: str | None = None created_by: str | None = None created_at: int | None = None @@ -272,54 +269,52 @@ def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: class ModelConfig(ResponseModel): opening_statement: str | None = None - suggested_questions: JSONValue | None = Field( + suggested_questions: Any | None = Field( default=None, validation_alias=AliasChoices("suggested_questions_list", "suggested_questions") ) - suggested_questions_after_answer: JSONValue | None = Field( + suggested_questions_after_answer: Any | None = Field( default=None, validation_alias=AliasChoices("suggested_questions_after_answer_dict", "suggested_questions_after_answer"), ) - speech_to_text: JSONValue | None = Field( + speech_to_text: Any | None = Field( default=None, validation_alias=AliasChoices("speech_to_text_dict", "speech_to_text") ) - text_to_speech: JSONValue | None = Field( + text_to_speech: Any | None = Field( default=None, validation_alias=AliasChoices("text_to_speech_dict", "text_to_speech") ) - retriever_resource: JSONValue | None = Field( + retriever_resource: Any | None = Field( default=None, validation_alias=AliasChoices("retriever_resource_dict", "retriever_resource") ) - annotation_reply: JSONValue | None = Field( + annotation_reply: Any | None = Field( default=None, validation_alias=AliasChoices("annotation_reply_dict", "annotation_reply") ) - more_like_this: JSONValue | None = Field( + more_like_this: Any | None = Field( default=None, validation_alias=AliasChoices("more_like_this_dict", "more_like_this") ) - sensitive_word_avoidance: JSONValue | None = Field( + sensitive_word_avoidance: Any | None = Field( default=None, validation_alias=AliasChoices("sensitive_word_avoidance_dict", "sensitive_word_avoidance") ) - external_data_tools: JSONValue | None = Field( + external_data_tools: Any | None = Field( default=None, validation_alias=AliasChoices("external_data_tools_list", "external_data_tools") ) - model: JSONValue | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model")) - user_input_form: JSONValue | None = Field( + model: Any | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model")) + user_input_form: Any | None = Field( default=None, validation_alias=AliasChoices("user_input_form_list", "user_input_form") ) dataset_query_variable: str | None = None pre_prompt: str | None = None - agent_mode: JSONValue | None = Field(default=None, validation_alias=AliasChoices("agent_mode_dict", "agent_mode")) + agent_mode: Any | None = Field(default=None, validation_alias=AliasChoices("agent_mode_dict", "agent_mode")) prompt_type: str | None = None - chat_prompt_config: JSONValue | None = Field( + chat_prompt_config: Any | None = Field( default=None, validation_alias=AliasChoices("chat_prompt_config_dict", "chat_prompt_config") ) - completion_prompt_config: JSONValue | None = Field( + completion_prompt_config: Any | None = Field( default=None, validation_alias=AliasChoices("completion_prompt_config_dict", "completion_prompt_config") ) - dataset_configs: JSONValue | None = Field( + dataset_configs: Any | None = Field( default=None, validation_alias=AliasChoices("dataset_configs_dict", "dataset_configs") ) - file_upload: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("file_upload_dict", "file_upload") - ) + file_upload: Any | None = Field(default=None, validation_alias=AliasChoices("file_upload_dict", "file_upload")) created_by: str | None = None created_at: int | None = None updated_by: str | None = None @@ -439,7 +434,7 @@ class AppDetail(ResponseModel): alias="model_config", ) workflow: WorkflowPartial | None = None - tracing: JSONValue | None = None + tracing: Any | None = None use_icon_as_answer_icon: bool | None = None created_by: str | None = None created_at: int | None = None @@ -485,6 +480,16 @@ class AppExportResponse(ResponseModel): data: str +class AppImportResponse(ResponseModel): + id: str + status: ImportStatus + app_id: str | None = None + app_mode: str | None = None + current_dsl_version: str + imported_dsl_version: str = "" + error: str = "" + + def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None: if FeatureService.get_system_features().webapp_auth.enabled: app_ids = [str(app.id) for app in apps] @@ -527,7 +532,9 @@ def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum) -register_response_schema_models(console_ns, AppTraceResponse, RedirectUrlResponse, SimpleResultResponse) +register_response_schema_models( + console_ns, RedirectUrlResponse, SimpleResultResponse, AppImportResponse, AppTraceResponse +) register_schema_models( console_ns, @@ -619,8 +626,8 @@ def get(self, current_tenant_id: str, current_user_id: str, session: Session): app_service = AppService() app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params, db.session) if not app_pagination: - empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) - return empty.model_dump(mode="json"), 200 + response = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) + return response.model_dump(mode="json"), 200 app_ids = [str(app.id) for app in app_pagination.items] permission_keys_map = permissions.app.permission_keys_by_resource_ids(app_ids) @@ -709,9 +716,7 @@ def get(self, current_tenant_id: str, current_user_id: str, session: Session): return empty.model_dump(mode="json"), 200 _enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id) - - pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True) - return pagination_model.model_dump(mode="json"), 200 + return AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json"), 200 @console_ns.route("/apps//star") @@ -730,7 +735,7 @@ class AppStarApi(Resource): @get_app_model(mode=None) def post(self, session: Session, current_user_id: str, app_model: App): AppService.star_app(session, app=app_model, account_id=current_user_id) - return dump_response(SimpleResultResponse, {"result": "success"}) + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.doc("unstar_app") @console_ns.doc(description="Remove the current account's star from an application") @@ -746,7 +751,7 @@ def post(self, session: Session, current_user_id: str, app_model: App): @get_app_model(mode=None) def delete(self, session: Session, current_user_id: str, app_model: App): AppService.unstar_app(session, app=app_model, account_id=current_user_id) - return dump_response(SimpleResultResponse, {"result": "success"}) + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/apps/") @@ -814,8 +819,7 @@ def put(self, app_model: App): "max_active_requests": args.max_active_requests or 0, } app_model = app_service.update_app(app_model, args_dict) - response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True) - return response_model.model_dump(mode="json") + return dump_response(AppDetailWithSite, app_model) @console_ns.doc("delete_app") @console_ns.doc(description="Delete application") @@ -843,6 +847,7 @@ class AppCopyApi(Resource): @console_ns.doc(params={"app_id": "Application ID to copy"}) @console_ns.expect(console_ns.models[CopyAppPayload.__name__]) @console_ns.response(201, "App copied successfully", console_ns.models[AppDetailWithSite.__name__]) + @console_ns.response(202, "App copy requires confirmation", console_ns.models[AppImportResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @setup_required @login_required @@ -872,10 +877,10 @@ def post(self, current_tenant_id: str, current_user: Account, app_model: App): ) if result.status == ImportStatus.FAILED: session.rollback() - return result.model_dump(mode="json"), 400 + return dump_response(AppImportResponse, result), 400 if result.status == ImportStatus.PENDING: session.rollback() - return result.model_dump(mode="json"), 202 + return dump_response(AppImportResponse, result), 202 session.commit() # Inherit web app permission from original app @@ -926,14 +931,14 @@ def get(self, app_model: App): """Export app""" args = AppExportQuery.model_validate(request.args.to_dict(flat=True)) - payload = AppExportResponse( + response = AppExportResponse( data=AppDslService.export_dsl( app_model=app_model, include_secret=args.include_secret, workflow_id=args.workflow_id, ) ) - return payload.model_dump(mode="json") + return response.model_dump(mode="json") @console_ns.route("/apps//publish-to-creators-platform") @@ -959,7 +964,7 @@ def post(self, current_user_id: str, app_model: App): claim_code = upload_dsl(dsl_bytes) redirect_url = get_redirect_url(current_user_id, claim_code) - return {"redirect_url": redirect_url} + return RedirectUrlResponse(redirect_url=redirect_url).model_dump(mode="json") @console_ns.route("/apps//name") @@ -980,8 +985,7 @@ def post(self, app_model: App): app_service = AppService() app_model = app_service.update_app_name(app_model, args.name) - response_model = AppDetail.model_validate(app_model, from_attributes=True) - return response_model.model_dump(mode="json") + return dump_response(AppDetail, app_model) @console_ns.route("/apps//icon") @@ -1008,8 +1012,7 @@ def post(self, app_model: App): args.icon_background or "", args.icon_type, ) - response_model = AppDetail.model_validate(app_model, from_attributes=True) - return response_model.model_dump(mode="json") + return dump_response(AppDetail, app_model) @console_ns.route("/apps//site-enable") @@ -1031,8 +1034,7 @@ def post(self, app_model: App): app_service = AppService() app_model = app_service.update_app_site_status(app_model, args.enable_site) - response_model = AppDetail.model_validate(app_model, from_attributes=True) - return response_model.model_dump(mode="json") + return dump_response(AppDetail, app_model) @console_ns.route("/apps//api-enable") @@ -1054,8 +1056,7 @@ def post(self, app_model: App): app_service = AppService() app_model = app_service.update_app_api_status(app_model, args.enable_api) - response_model = AppDetail.model_validate(app_model, from_attributes=True) - return response_model.model_dump(mode="json") + return dump_response(AppDetail, app_model) @console_ns.route("/apps//trace") @@ -1078,7 +1079,7 @@ def get(self, session: Session, app_model: App): """Get app trace""" app_trace_config = OpsTraceManager.get_app_tracing_config(app_model.id, session) - return app_trace_config + return dump_response(AppTraceResponse, app_trace_config) @console_ns.doc("update_app_trace") @console_ns.doc(description="Update app tracing configuration") @@ -1106,4 +1107,4 @@ def post(self, app_model: App): tracing_provider=args.tracing_provider, ) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py index b66c97c274ca16..77c30614081160 100644 --- a/api/controllers/console/app/audio.py +++ b/api/controllers/console/app/audio.py @@ -1,5 +1,4 @@ import logging -from typing import Any from flask import request from flask_restx import Resource @@ -7,7 +6,6 @@ from werkzeug.exceptions import InternalServerError import services -from controllers.common.fields import AudioBinaryResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( @@ -31,7 +29,9 @@ ) from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from extensions.ext_database import db +from fields.base import ResponseModel from graphon.model_runtime.errors.invoke import InvokeError +from libs.helper import dump_response from libs.login import login_required from models import App, AppMode from services.audio_service import AudioService @@ -56,16 +56,27 @@ class TextToSpeechVoiceQuery(BaseModel): language: str = Field(..., description="Language code") -class AudioTranscriptResponse(BaseModel): +class AudioTranscriptResponse(ResponseModel): text: str = Field(description="Transcribed text from audio") -class TextToSpeechVoiceListResponse(RootModel[list[dict[str, Any]]]): - root: list[dict[str, Any]] +class TextToSpeechVoiceResponse(ResponseModel): + # see api/core/plugin/impl/model.py + name: str = Field(description="Voice display name") + value: str = Field(description="Voice identifier") -register_schema_models(console_ns, AudioTranscriptResponse, TextToSpeechPayload, TextToSpeechVoiceQuery) -register_response_schema_models(console_ns, AudioBinaryResponse, TextToSpeechVoiceListResponse) +class TextToSpeechVoiceListResponse(RootModel[list[TextToSpeechVoiceResponse]]): + root: list[TextToSpeechVoiceResponse] = Field(description="Available voices") + + +register_schema_models(console_ns, TextToSpeechPayload, TextToSpeechVoiceQuery) +register_response_schema_models( + console_ns, + AudioTranscriptResponse, + TextToSpeechVoiceResponse, + TextToSpeechVoiceListResponse, +) @console_ns.route("/apps//audio-to-text") @@ -94,7 +105,7 @@ def post(self, app_model: App): end_user=None, ) - return response + return dump_response(AudioTranscriptResponse, response) except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") raise AppUnavailableError() @@ -127,11 +138,8 @@ class ChatMessageTextApi(Resource): @console_ns.doc(description="Convert text to speech for chat messages") @console_ns.doc(params={"app_id": "App ID"}) @console_ns.expect(console_ns.models[TextToSpeechPayload.__name__]) - @console_ns.response( - 200, - "Text to speech conversion successful", - console_ns.models[AudioBinaryResponse.__name__], - ) + # TTS returns provider audio bytes, so the success response is intentionally schema-less. + @console_ns.response(200, "Text to speech conversion successful") @console_ns.response(400, "Bad request - Invalid parameters") @setup_required @login_required @@ -141,7 +149,8 @@ def post(self, app_model: App): try: payload = TextToSpeechPayload.model_validate(console_ns.payload) - response = AudioService.transcript_tts( + # response-contract:ignore + return AudioService.transcript_tts( app_model=app_model, session=db.session, text=payload.text, @@ -149,7 +158,6 @@ def post(self, app_model: App): message_id=payload.message_id, is_draft=True, ) - return response except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") raise AppUnavailableError() @@ -180,8 +188,7 @@ def post(self, app_model: App): class TextModesApi(Resource): @console_ns.doc("get_text_to_speech_voices") @console_ns.doc(description="Get available TTS voices for a specific language") - @console_ns.doc(params={"app_id": "App ID"}) - @console_ns.doc(params=query_params_from_model(TextToSpeechVoiceQuery)) + @console_ns.doc(params={"app_id": "App ID", **query_params_from_model(TextToSpeechVoiceQuery)}) @console_ns.response( 200, "TTS voices retrieved successfully", @@ -202,7 +209,7 @@ def get(self, app_model: App): language=args.language, ) - return response + return dump_response(TextToSpeechVoiceListResponse, response) except services.errors.audio.ProviderNotSupportTextToSpeechLanageServiceError: raise AppUnavailableError("Text to audio voices language parameter loss.") except NoAudioUploadedServiceError: diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 545fad34cdeca2..73201af44c6f0a 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -8,7 +8,7 @@ from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services -from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse +from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.agent.app_helpers import resolve_agent_app_model @@ -103,7 +103,7 @@ def validate_uuid(cls, value: str | None) -> str | None: register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload) -register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse) +register_response_schema_models(console_ns, SimpleResultResponse) # define completion message api for user @@ -113,7 +113,7 @@ class CompletionMessageApi(Resource): @console_ns.doc(description="Generate completion message for debugging") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[CompletionMessagePayload.__name__]) - @console_ns.response(200, "Completion generated successfully", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Completion generated successfully") @console_ns.response(400, "Invalid request parameters") @console_ns.response(404, "App not found") @setup_required @@ -134,6 +134,7 @@ def post(self, current_user: Account, app_model: App): app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -177,7 +178,7 @@ def post(self, current_user_id: str, app_model: App, task_id: str): app_mode=AppMode.value_of(app_model.mode), ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route("/apps//chat-messages") @@ -186,7 +187,7 @@ class ChatMessageApi(Resource): @console_ns.doc(description="Generate chat message for debugging") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[ChatMessagePayload.__name__]) - @console_ns.response(200, "Chat message generated successfully", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Chat message generated successfully") @console_ns.response(400, "Invalid request parameters") @console_ns.response(404, "App or conversation not found") @setup_required @@ -207,7 +208,7 @@ class AgentChatMessageApi(Resource): @console_ns.doc(description="Generate an Agent App chat message for debugging") @console_ns.doc(params={"agent_id": "Agent ID"}) @console_ns.expect(console_ns.models[ChatMessagePayload.__name__]) - @console_ns.response(200, "Chat message generated successfully", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Chat message generated successfully") @console_ns.response(400, "Invalid request parameters") @console_ns.response(404, "Agent or conversation not found") @setup_required @@ -315,6 +316,7 @@ def _create_chat_message( app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -348,4 +350,4 @@ def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str): app_mode=AppMode.value_of(app_model.mode), ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index ec34c26fedd0fe..6387ea441e5e98 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload from werkzeug.exceptions import NotFound -from controllers.common.schema import query_params_from_model, register_schema_models +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( @@ -39,6 +39,7 @@ ConversationWithSummaryPagination as ConversationWithSummaryPaginationResponse, ) from libs.datetime_utils import naive_utc_now, parse_time_range +from libs.helper import dump_response from libs.login import login_required from models import Conversation, EndUser, Message, MessageAnnotation from models.account import Account @@ -79,13 +80,14 @@ class ChatConversationQuery(BaseConversationQuery): console_ns, CompletionConversationQuery, ChatConversationQuery, +) +register_response_schema_models( + console_ns, ConversationResponse, ConversationPaginationResponse, ConversationMessageDetailResponse, ConversationWithSummaryPaginationResponse, ConversationDetailResponse, - CompletionConversationQuery, - ChatConversationQuery, ) @@ -93,8 +95,7 @@ class ChatConversationQuery(BaseConversationQuery): class CompletionConversationApi(Resource): @console_ns.doc("list_completion_conversations") @console_ns.doc(description="Get completion conversations with pagination and filtering") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(CompletionConversationQuery)) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(CompletionConversationQuery)}) @console_ns.response(200, "Success", console_ns.models[ConversationPaginationResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @setup_required @@ -157,9 +158,7 @@ def get(self, current_user: Account, app_model: App): conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False) - return ConversationPaginationResponse.model_validate(conversations, from_attributes=True).model_dump( - mode="json" - ) + return dump_response(ConversationPaginationResponse, conversations) @console_ns.route("/apps//completion-conversations/") @@ -179,9 +178,9 @@ class CompletionConversationDetailApi(Resource): @get_app_model(mode=AppMode.COMPLETION) def get(self, current_user: Account, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) - return ConversationMessageDetailResponse.model_validate( - _get_conversation(current_user, app_model, conversation_id_str), from_attributes=True - ).model_dump(mode="json") + return dump_response( + ConversationMessageDetailResponse, _get_conversation(current_user, app_model, conversation_id_str) + ) @console_ns.doc("delete_completion_conversation") @console_ns.doc(description="Delete a completion conversation") @@ -211,8 +210,7 @@ def delete(self, current_user: Account, app_model: App, conversation_id: UUID): class ChatConversationApi(Resource): @console_ns.doc("list_chat_conversations") @console_ns.doc(description="Get chat conversations with pagination, filtering and summary") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(ChatConversationQuery)) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(ChatConversationQuery)}) @console_ns.response(200, "Success", console_ns.models[ConversationWithSummaryPaginationResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @setup_required @@ -314,9 +312,7 @@ def get(self, current_user: Account, app_model: App): conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False) - return ConversationWithSummaryPaginationResponse.model_validate(conversations, from_attributes=True).model_dump( - mode="json" - ) + return dump_response(ConversationWithSummaryPaginationResponse, conversations) @console_ns.route("/apps//chat-conversations/") @@ -336,9 +332,9 @@ class ChatConversationDetailApi(Resource): @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def get(self, current_user: Account, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) - return ConversationDetailResponse.model_validate( - _get_conversation(current_user, app_model, conversation_id_str), from_attributes=True - ).model_dump(mode="json") + return dump_response( + ConversationDetailResponse, _get_conversation(current_user, app_model, conversation_id_str) + ) @console_ns.doc("delete_chat_conversation") @console_ns.doc(description="Delete a chat conversation") diff --git a/api/controllers/console/app/conversation_variables.py b/api/controllers/console/app/conversation_variables.py index 3069dd3011c416..aa8090f0440ded 100644 --- a/api/controllers/console/app/conversation_variables.py +++ b/api/controllers/console/app/conversation_variables.py @@ -22,7 +22,7 @@ from extensions.ext_database import db from fields._value_type_serializer import serialize_value_type from fields.base import ResponseModel -from libs.helper import to_timestamp +from libs.helper import dump_response, to_timestamp from libs.login import login_required from models import ConversationVariable from models.model import App, AppMode @@ -119,7 +119,8 @@ def get(self, app_model: App): with sessionmaker(db.engine, expire_on_commit=False).begin() as session: rows = session.scalars(stmt).all() - response = PaginatedConversationVariableResponse.model_validate( + return dump_response( + PaginatedConversationVariableResponse, { "page": page, "limit": page_size, @@ -135,6 +136,5 @@ def get(self, app_model: App): ) for row in rows ], - } + }, ) - return response.model_dump(mode="json") diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index 6d6a56b5e1d019..0cdc261ef2f89e 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -22,7 +22,7 @@ ) from extensions.ext_database import db from fields.base import ResponseModel -from libs.helper import to_timestamp +from libs.helper import dump_response, to_timestamp from libs.login import login_required from models.enums import AppMCPServerStatus from models.model import App, AppMCPServer @@ -92,7 +92,7 @@ def get(self, app_model: App): server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1)) if server is None: return {} - return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json") + return dump_response(AppMCPServerResponse, server) @console_ns.doc("create_app_mcp_server") @console_ns.doc(description="Create MCP server configuration for an application") @@ -127,7 +127,7 @@ def post(self, current_tenant_id: str, app_model: App): ) db.session.add(server) db.session.commit() - return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json"), 201 + return dump_response(AppMCPServerResponse, server), 201 @console_ns.doc("update_app_mcp_server") @console_ns.doc(description="Update MCP server configuration for an application") @@ -165,7 +165,7 @@ def put(self, app_model: App): except ValueError: raise ValueError("Invalid status") db.session.commit() - return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json") + return dump_response(AppMCPServerResponse, server) @console_ns.route("/apps//server/refresh") @@ -192,4 +192,4 @@ def get(self, current_tenant_id: str, server_id: UUID): raise NotFound() server.server_code = AppMCPServer.generate_server_code(16) db.session.commit() - return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json") + return dump_response(AppMCPServerResponse, server) diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 195a41f2888881..b51506c6e7d8ac 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -1,5 +1,4 @@ import logging -from datetime import datetime from typing import Literal from uuid import UUID @@ -38,16 +37,10 @@ from extensions.ext_database import db from fields.base import ResponseModel from fields.conversation_fields import ( - AgentThought, - ConversationAnnotation, - ConversationAnnotationHitHistory, - Feedback, - JSONValue, - MessageFile, - format_files_contained, + MessageDetail as BaseMessageDetailResponse, ) from graphon.model_runtime.errors.invoke import InvokeError -from libs.helper import to_timestamp, uuid_value +from libs.helper import dump_response, uuid_value from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.login import login_required from models.account import Account @@ -111,49 +104,16 @@ def parse_bool(cls, value: bool | str | None) -> bool | None: raise ValueError("has_comment must be a boolean value") -class AnnotationCountResponse(BaseModel): +class AnnotationCountResponse(ResponseModel): count: int = Field(description="Number of annotations") -class SuggestedQuestionsResponse(BaseModel): +class SuggestedQuestionsResponse(ResponseModel): data: list[str] = Field(description="Suggested question") -class MessageDetailResponse(ResponseModel): - id: str - conversation_id: str - inputs: dict[str, JSONValue] - query: str - message: JSONValue | None = None - message_tokens: int | None = None - answer: str = Field(validation_alias="re_sign_file_url_answer") - answer_tokens: int | None = None - provider_response_latency: float | None = None - from_source: str - from_end_user_id: str | None = None - from_account_id: str | None = None - feedbacks: list[Feedback] = Field(default_factory=list) - workflow_run_id: str | None = None - annotation: ConversationAnnotation | None = None - annotation_hit_history: ConversationAnnotationHitHistory | None = None - created_at: int | None = None - agent_thoughts: list[AgentThought] = Field(default_factory=list) - message_files: list[MessageFile] = Field(default_factory=list) +class MessageDetailResponse(BaseMessageDetailResponse): extra_contents: list[ExecutionExtraContentDomainModel] = Field(default_factory=list) - metadata: JSONValue | None = Field(default=None, validation_alias="message_metadata_dict") - status: str - error: str | None = None - parent_message_id: str | None = None - - @field_validator("inputs", mode="before") - @classmethod - def _normalize_inputs(cls, value: JSONValue) -> JSONValue: - return format_files_contained(value) - - @field_validator("created_at", mode="before") - @classmethod - def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - return to_timestamp(value) class MessageInfiniteScrollPaginationResponse(ResponseModel): @@ -167,20 +127,23 @@ class MessageInfiniteScrollPaginationResponse(ResponseModel): ChatMessagesQuery, MessageFeedbackPayload, FeedbackExportQuery, +) +register_response_schema_models( + console_ns, AnnotationCountResponse, SuggestedQuestionsResponse, MessageDetailResponse, MessageInfiniteScrollPaginationResponse, + SimpleResultResponse, + TextFileResponse, ) -register_response_schema_models(console_ns, SimpleResultResponse, TextFileResponse) @console_ns.route("/apps//chat-messages") class ChatMessageListApi(Resource): @console_ns.doc("list_chat_messages") @console_ns.doc(description="Get chat messages for a conversation with pagination") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(ChatMessagesQuery)) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(ChatMessagesQuery)}) @console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__]) @console_ns.response(404, "Conversation not found") @login_required @@ -270,7 +233,7 @@ def get(self, app_model: App): select(func.count(MessageAnnotation.id)).where(MessageAnnotation.app_id == app_model.id) ) - return {"count": count} + return AnnotationCountResponse(count=count or 0).model_dump(mode="json") @console_ns.route("/apps//chat-messages//suggested-questions") @@ -319,13 +282,12 @@ def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, mes class MessageFeedbackExportApi(Resource): @console_ns.doc("export_feedbacks") @console_ns.doc(description="Export user feedback data for Google Sheets") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(FeedbackExportQuery)) @console_ns.response( 200, "Feedback data exported successfully", console_ns.models[TextFileResponse.__name__], ) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(FeedbackExportQuery)}) @console_ns.response(400, "Invalid parameters") @console_ns.response(500, "Internal server error") @setup_required @@ -350,7 +312,6 @@ def get(self, app_model: App): end_date=args.end_date, format_type=args.format, ) - return export_data except ValueError as e: @@ -461,16 +422,16 @@ def _list_chat_messages(*, app_model: App, current_user: Account | None = None): history_messages = list(reversed(history_messages)) attach_message_extra_contents(history_messages) - return MessageInfiniteScrollPaginationResponse.model_validate( + return dump_response( + MessageInfiniteScrollPaginationResponse, InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more), - from_attributes=True, - ).model_dump(mode="json") + ) def _update_message_feedback(*, current_user: Account, app_model: App): args = MessageFeedbackPayload.model_validate(console_ns.payload) - message_id = str(args.message_id) + message_id = args.message_id message = db.session.scalar( select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1) @@ -505,7 +466,7 @@ def _update_message_feedback(*, current_user: Account, app_model: App): db.session.commit() - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") def _get_message_suggested_questions(*, current_user: Account, app_model: App, message_id: UUID): @@ -533,7 +494,7 @@ def _get_message_suggested_questions(*, current_user: Account, app_model: App, m logger.exception("internal server error.") raise InternalServerError() - return {"data": questions} + return dump_response(SuggestedQuestionsResponse, {"data": questions}) def _get_message_detail(*, app_model: App, message_id: UUID): @@ -547,4 +508,4 @@ def _get_message_detail(*, app_model: App, message_id: UUID): raise NotFound("Message Not Exists.") attach_message_extra_contents([message]) - return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json") + return dump_response(MessageDetailResponse, message) diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index edc79f8fbc6d14..e66f66ab2d73d2 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -22,6 +22,7 @@ from extensions.ext_database import db from fields.base import ResponseModel from libs.datetime_utils import naive_utc_now +from libs.helper import dump_response from libs.login import login_required from models import Site from models.account import Account @@ -124,7 +125,7 @@ def post(self, current_user: Account, app_model: App): site.updated_at = naive_utc_now() db.session.commit() - return AppSiteResponse.model_validate(site, from_attributes=True).model_dump(mode="json") + return dump_response(AppSiteResponse, site) @console_ns.route("/apps//site/access-token-reset") @@ -153,4 +154,4 @@ def post(self, current_user: Account, app_model: App): site.updated_at = naive_utc_now() db.session.commit() - return AppSiteResponse.model_validate(site, from_attributes=True).model_dump(mode="json") + return dump_response(AppSiteResponse, site) diff --git a/api/controllers/console/app/statistic.py b/api/controllers/console/app/statistic.py index fbb6d3e987f179..c7c3c9e64b69e4 100644 --- a/api/controllers/console/app/statistic.py +++ b/api/controllers/console/app/statistic.py @@ -1,7 +1,7 @@ from decimal import Decimal import sqlalchemy as sa -from flask import abort, jsonify, request +from flask import abort, request from flask_restx import Resource from pydantic import BaseModel, Field, field_validator @@ -20,7 +20,7 @@ from extensions.ext_database import db from fields.base import ResponseModel from libs.datetime_utils import parse_time_range -from libs.helper import convert_datetime_to_date +from libs.helper import convert_datetime_to_date, dump_response from libs.login import login_required from models import AppMode from models.account import Account @@ -44,8 +44,15 @@ class DailyMessageStatisticItem(ResponseModel): message_count: int -class DailyMessageStatisticResponse(ResponseModel): - data: list[DailyMessageStatisticItem] +register_schema_models(console_ns, StatisticTimeRangeQuery) + + +class StatisticDataResponse[T](ResponseModel): + data: list[T] + + +class DailyMessageStatisticResponse(StatisticDataResponse[DailyMessageStatisticItem]): + pass class DailyConversationStatisticItem(ResponseModel): @@ -53,8 +60,8 @@ class DailyConversationStatisticItem(ResponseModel): conversation_count: int -class DailyConversationStatisticResponse(ResponseModel): - data: list[DailyConversationStatisticItem] +class DailyConversationStatisticResponse(StatisticDataResponse[DailyConversationStatisticItem]): + pass class DailyTerminalStatisticItem(ResponseModel): @@ -62,19 +69,19 @@ class DailyTerminalStatisticItem(ResponseModel): terminal_count: int -class DailyTerminalStatisticResponse(ResponseModel): - data: list[DailyTerminalStatisticItem] +class DailyTerminalStatisticResponse(StatisticDataResponse[DailyTerminalStatisticItem]): + pass class DailyTokenCostStatisticItem(ResponseModel): date: str - token_count: int - total_price: str | float - currency: str + token_count: int | None = None + total_price: Decimal | None = None + currency: str | None = None -class DailyTokenCostStatisticResponse(ResponseModel): - data: list[DailyTokenCostStatisticItem] +class DailyTokenCostStatisticResponse(StatisticDataResponse[DailyTokenCostStatisticItem]): + pass class AverageSessionInteractionStatisticItem(ResponseModel): @@ -82,8 +89,8 @@ class AverageSessionInteractionStatisticItem(ResponseModel): interactions: float -class AverageSessionInteractionStatisticResponse(ResponseModel): - data: list[AverageSessionInteractionStatisticItem] +class AverageSessionInteractionStatisticResponse(StatisticDataResponse[AverageSessionInteractionStatisticItem]): + pass class UserSatisfactionRateStatisticItem(ResponseModel): @@ -91,8 +98,8 @@ class UserSatisfactionRateStatisticItem(ResponseModel): rate: float -class UserSatisfactionRateStatisticResponse(ResponseModel): - data: list[UserSatisfactionRateStatisticItem] +class UserSatisfactionRateStatisticResponse(StatisticDataResponse[UserSatisfactionRateStatisticItem]): + pass class AverageResponseTimeStatisticItem(ResponseModel): @@ -100,8 +107,8 @@ class AverageResponseTimeStatisticItem(ResponseModel): latency: float -class AverageResponseTimeStatisticResponse(ResponseModel): - data: list[AverageResponseTimeStatisticItem] +class AverageResponseTimeStatisticResponse(StatisticDataResponse[AverageResponseTimeStatisticItem]): + pass class TokensPerSecondStatisticItem(ResponseModel): @@ -109,20 +116,27 @@ class TokensPerSecondStatisticItem(ResponseModel): tps: float -class TokensPerSecondStatisticResponse(ResponseModel): - data: list[TokensPerSecondStatisticItem] +class TokensPerSecondStatisticResponse(StatisticDataResponse[TokensPerSecondStatisticItem]): + pass -register_schema_models(console_ns, StatisticTimeRangeQuery) register_response_schema_models( console_ns, + DailyMessageStatisticItem, DailyMessageStatisticResponse, + DailyConversationStatisticItem, DailyConversationStatisticResponse, + DailyTerminalStatisticItem, DailyTerminalStatisticResponse, + DailyTokenCostStatisticItem, DailyTokenCostStatisticResponse, + AverageSessionInteractionStatisticItem, AverageSessionInteractionStatisticResponse, + UserSatisfactionRateStatisticItem, UserSatisfactionRateStatisticResponse, + AverageResponseTimeStatisticItem, AverageResponseTimeStatisticResponse, + TokensPerSecondStatisticItem, TokensPerSecondStatisticResponse, ) @@ -131,8 +145,7 @@ class TokensPerSecondStatisticResponse(ResponseModel): class DailyMessageStatistic(Resource): @console_ns.doc("get_daily_message_statistics") @console_ns.doc(description="Get daily message statistics for an application") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(StatisticTimeRangeQuery)}) @console_ns.response( 200, "Daily message statistics retrieved successfully", @@ -185,15 +198,14 @@ def get(self, account: Account, app_model: App): for i in rs: response_data.append({"date": str(i.date), "message_count": i.message_count}) - return jsonify({"data": response_data}) + return dump_response(DailyMessageStatisticResponse, {"data": response_data}) @console_ns.route("/apps//statistics/daily-conversations") class DailyConversationStatistic(Resource): @console_ns.doc("get_daily_conversation_statistics") @console_ns.doc(description="Get daily conversation statistics for an application") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(StatisticTimeRangeQuery)}) @console_ns.response( 200, "Daily conversation statistics retrieved successfully", @@ -245,15 +257,14 @@ def get(self, account: Account, app_model: App): for i in rs: response_data.append({"date": str(i.date), "conversation_count": i.conversation_count}) - return jsonify({"data": response_data}) + return dump_response(DailyConversationStatisticResponse, {"data": response_data}) @console_ns.route("/apps//statistics/daily-end-users") class DailyTerminalsStatistic(Resource): @console_ns.doc("get_daily_terminals_statistics") @console_ns.doc(description="Get daily terminal/end-user statistics for an application") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(StatisticTimeRangeQuery)}) @console_ns.response( 200, "Daily terminal statistics retrieved successfully", @@ -306,15 +317,14 @@ def get(self, account: Account, app_model: App): for i in rs: response_data.append({"date": str(i.date), "terminal_count": i.terminal_count}) - return jsonify({"data": response_data}) + return dump_response(DailyTerminalStatisticResponse, {"data": response_data}) @console_ns.route("/apps//statistics/token-costs") class DailyTokenCostStatistic(Resource): @console_ns.doc("get_daily_token_cost_statistics") @console_ns.doc(description="Get daily token cost statistics for an application") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(StatisticTimeRangeQuery)}) @console_ns.response( 200, "Daily token cost statistics retrieved successfully", @@ -370,15 +380,14 @@ def get(self, account: Account, app_model: App): {"date": str(i.date), "token_count": i.token_count, "total_price": i.total_price, "currency": "USD"} ) - return jsonify({"data": response_data}) + return dump_response(DailyTokenCostStatisticResponse, {"data": response_data}) @console_ns.route("/apps//statistics/average-session-interactions") class AverageSessionInteractionStatistic(Resource): @console_ns.doc("get_average_session_interaction_statistics") @console_ns.doc(description="Get average session interaction statistics for an application") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(StatisticTimeRangeQuery)}) @console_ns.response( 200, "Average session interaction statistics retrieved successfully", @@ -450,15 +459,14 @@ def get(self, account: Account, app_model: App): {"date": str(i.date), "interactions": float(i.interactions.quantize(Decimal("0.01")))} ) - return jsonify({"data": response_data}) + return dump_response(AverageSessionInteractionStatisticResponse, {"data": response_data}) @console_ns.route("/apps//statistics/user-satisfaction-rate") class UserSatisfactionRateStatistic(Resource): @console_ns.doc("get_user_satisfaction_rate_statistics") @console_ns.doc(description="Get user satisfaction rate statistics for an application") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(StatisticTimeRangeQuery)}) @console_ns.response( 200, "User satisfaction rate statistics retrieved successfully", @@ -520,15 +528,14 @@ def get(self, account: Account, app_model: App): } ) - return jsonify({"data": response_data}) + return dump_response(UserSatisfactionRateStatisticResponse, {"data": response_data}) @console_ns.route("/apps//statistics/average-response-time") class AverageResponseTimeStatistic(Resource): @console_ns.doc("get_average_response_time_statistics") @console_ns.doc(description="Get average response time statistics for an application") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(StatisticTimeRangeQuery)}) @console_ns.response( 200, "Average response time statistics retrieved successfully", @@ -581,15 +588,14 @@ def get(self, account: Account, app_model: App): for i in rs: response_data.append({"date": str(i.date), "latency": round(i.latency * 1000, 4)}) - return jsonify({"data": response_data}) + return dump_response(AverageResponseTimeStatisticResponse, {"data": response_data}) @console_ns.route("/apps//statistics/tokens-per-second") class TokensPerSecondStatistic(Resource): @console_ns.doc("get_tokens_per_second_statistics") @console_ns.doc(description="Get tokens per second statistics for an application") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(StatisticTimeRangeQuery)}) @console_ns.response( 200, "Tokens per second statistics retrieved successfully", @@ -645,4 +651,4 @@ def get(self, account: Account, app_model: App): for i in rs: response_data.append({"date": str(i.date), "tps": round(i.tokens_per_second, 4)}) - return jsonify({"data": response_data}) + return dump_response(TokensPerSecondStatisticResponse, {"data": response_data}) diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index aff320352333b7..e92d3266d9e495 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -1,19 +1,19 @@ import json import logging -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from datetime import datetime -from typing import Any, NotRequired, TypedDict, cast +from typing import Any, Literal, NotRequired, TypedDict, cast from flask import abort, request -from flask_restx import Resource, fields -from pydantic import AliasChoices, BaseModel, Field, RootModel, ValidationError, field_validator +from flask_restx import Resource +from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload from controllers.common.errors import InvalidArgumentError -from controllers.common.fields import GeneratedAppResponse, NewAppResponse, SimpleResultResponse +from controllers.common.fields import NewAppResponse, SimpleResultResponse from controllers.common.schema import ( query_params_from_model, register_response_schema_model, @@ -21,11 +21,7 @@ register_schema_models, ) from controllers.console import console_ns -from controllers.console.app.error import ( - ConversationCompletedError, - DraftWorkflowNotExist, - DraftWorkflowNotSync, -) +from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync from controllers.console.app.permission_keys import get_app_permission_keys from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( @@ -58,7 +54,7 @@ from extensions.ext_redis import redis_client from factories import file_factory, variable_factory from fields.base import ResponseModel -from fields.member_fields import SimpleAccount +from fields.member_fields import SimpleAccountResponse from fields.workflow_run_fields import WorkflowRunNodeExecutionResponse from graphon.enums import NodeType from graphon.file import File @@ -69,7 +65,7 @@ from graphon.variables.exc import VariableError from libs import helper from libs.datetime_utils import naive_utc_now -from libs.helper import TimestampField, dump_response, to_timestamp, uuid_value +from libs.helper import dump_response, to_timestamp, uuid_value from libs.login import login_required from models import Account, App from models.model import AppMode @@ -103,20 +99,16 @@ class SyncDraftWorkflowPayload(BaseModel): graph: dict[str, Any] features: dict[str, Any] hash: str | None = None - environment_variables: list[dict[str, Any]] = Field( - default_factory=list, - ) - conversation_variables: list[dict[str, Any]] = Field( - default_factory=list, - ) + environment_variables: list[dict[str, Any]] = Field(default_factory=list) + conversation_variables: list[dict[str, Any]] = Field(default_factory=list) class BaseWorkflowRunPayload(BaseModel): - files: list[dict[str, Any]] | None = Field(default=None) + files: list[dict[str, Any]] | None = None class AdvancedChatWorkflowRunPayload(BaseWorkflowRunPayload): - inputs: dict[str, Any] | None = Field(default=None) + inputs: dict[str, Any] | None = None query: str = "" conversation_id: str | None = None parent_message_id: str | None = None @@ -130,11 +122,11 @@ def validate_uuid(cls, value: str | None) -> str | None: class IterationNodeRunPayload(BaseModel): - inputs: dict[str, Any] | None = Field(default=None) + inputs: dict[str, Any] | None = None class LoopNodeRunPayload(BaseModel): - inputs: dict[str, Any] | None = Field(default=None) + inputs: dict[str, Any] | None = None class DraftWorkflowRunPayload(BaseWorkflowRunPayload): @@ -159,10 +151,7 @@ class ConvertToWorkflowPayload(BaseModel): class WorkflowFeaturesPayload(BaseModel): - features: dict[str, Any] = Field( - ..., - description="Workflow feature configuration", - ) + features: dict[str, Any] = Field(..., description="Workflow feature configuration") class WorkflowOnlineUsersPayload(BaseModel): @@ -197,7 +186,7 @@ class PipelineVariableResponse(ResponseModel): max_length: int | None = None required: bool unit: str | None = None - default_value: Any = Field(default=None) + default_value: Any = None options: list[str] | None = None placeholder: str | None = None tooltips: str | None = None @@ -220,21 +209,17 @@ class WorkflowEnvironmentVariableResponse(ResponseModel): class WorkflowResponse(ResponseModel): id: str - graph: dict[str, Any] = Field( - validation_alias=AliasChoices("graph_dict", "graph"), - ) - features: dict[str, Any] = Field( - validation_alias=AliasChoices("features_dict", "features"), - ) + graph: dict[str, Any] = Field(validation_alias=AliasChoices("graph_dict", "graph")) + features: dict[str, Any] = Field(validation_alias=AliasChoices("features_dict", "features")) hash: str = Field(validation_alias=AliasChoices("unique_hash", "hash")) version: str marked_name: str marked_comment: str - created_by: SimpleAccount | None = Field( + created_by: SimpleAccountResponse | None = Field( default=None, validation_alias=AliasChoices("created_by_account", "created_by") ) created_at: int - updated_by: SimpleAccount | None = Field( + updated_by: SimpleAccountResponse | None = Field( default=None, validation_alias=AliasChoices("updated_by_account", "updated_by") ) updated_at: int @@ -267,59 +252,66 @@ class WorkflowPaginationResponse(ResponseModel): has_more: bool -class WorkflowOnlineUser(ResponseModel): - user_id: str - username: str - avatar: str | None = None +class SyncDraftWorkflowResponse(ResponseModel): + result: str + hash: str + updated_at: int -class WorkflowOnlineUsersByApp(ResponseModel): - app_id: str - users: list[WorkflowOnlineUser] +class PublishWorkflowResponse(ResponseModel): + result: str + created_at: int -class WorkflowOnlineUsersResponse(ResponseModel): - data: list[WorkflowOnlineUsersByApp] +class HumanInputUserActionResponse(ResponseModel): + id: str + title: str + button_style: str = "default" -class WorkflowPublishResponse(ResponseModel): - result: str - created_at: int +class HumanInputFormPreviewResponse(ResponseModel): + # Draft previews are shape-compatible with live human_input_required events, + # but they do not persist a form or mint recipient tokens (no expiration_time) + type_: Literal["human_input_required"] = Field(default="human_input_required", alias="TYPE") + form_id: str + form_content: str + inputs: list[Mapping[str, Any]] = Field(default_factory=list) + actions: list[HumanInputUserActionResponse] = Field(default_factory=list) + node_id: str + node_title: str + resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) + display_in_ui: bool = Field(default=False, description="Always false for draft preview responses.") + form_token: str | None = Field(default=None, description="Always null for draft preview responses.") + expiration_time: int | None = Field(default=None, description="Always null for draft preview responses.") -class WorkflowRestoreResponse(ResponseModel): - result: str - hash: str - updated_at: int +class HumanInputDeliveryTestResponse(ResponseModel): + pass -class DefaultBlockConfigsResponse(RootModel[list[dict[str, Any]]]): - root: list[dict[str, Any]] +class TriggerDebugWaitingResponse(ResponseModel): + status: Literal["waiting"] + retry_in: int -class DefaultBlockConfigResponse(RootModel[dict[str, Any]]): - root: dict[str, Any] +class TriggerDebugErrorResponse(ResponseModel): + status: Literal["error"] + error: str | None = None -class HumanInputFormPreviewResponse(ResponseModel): - form_id: str - node_id: str - node_title: str - form_content: str - inputs: list[dict[str, Any]] = Field(default_factory=list) - actions: list[dict[str, Any]] = Field(default_factory=list) - display_in_ui: bool | None = None - form_token: str | None = None - resolved_default_values: dict[str, Any] = Field(default_factory=dict) - expiration_time: int | None = None +class WorkflowOnlineUser(ResponseModel): + user_id: str + username: str + avatar: str | None = None -class HumanInputFormSubmitResponse(RootModel[dict[str, Any]]): - root: dict[str, Any] +class WorkflowOnlineUsersByApp(ResponseModel): + app_id: str + users: list[WorkflowOnlineUser] -class EmptyObjectResponse(RootModel[dict[str, Any]]): - root: dict[str, Any] +class WorkflowOnlineUsersResponse(ResponseModel): + data: list[WorkflowOnlineUsersByApp] class DraftWorkflowTriggerRunPayload(BaseModel): @@ -356,19 +348,19 @@ class DraftWorkflowTriggerRunAllPayload(BaseModel): WorkflowEnvironmentVariableResponse, WorkflowResponse, WorkflowPaginationResponse, + SyncDraftWorkflowResponse, + PublishWorkflowResponse, + HumanInputUserActionResponse, + HumanInputFormPreviewResponse, + HumanInputDeliveryTestResponse, + TriggerDebugWaitingResponse, + TriggerDebugErrorResponse, WorkflowOnlineUser, WorkflowOnlineUsersByApp, WorkflowOnlineUsersResponse, - WorkflowPublishResponse, - WorkflowRestoreResponse, - DefaultBlockConfigsResponse, - DefaultBlockConfigResponse, - HumanInputFormPreviewResponse, - HumanInputFormSubmitResponse, - EmptyObjectResponse, - GeneratedAppResponse, NewAppResponse, SimpleResultResponse, + WorkflowRunNodeExecutionResponse, ) @@ -426,6 +418,14 @@ def _serialize_environment_variable(value: Any) -> EnvironmentVariableResponseDi return value +def _trigger_debug_waiting_response() -> dict[str, int | str]: + return TriggerDebugWaitingResponse(status="waiting", retry_in=LISTENING_RETRY_IN).model_dump(mode="json") + + +def _trigger_debug_error_response(error: str | None = None) -> dict[str, str]: + return TriggerDebugErrorResponse(status="error", error=error).model_dump(mode="json", exclude_none=True) + + @console_ns.route("/apps//workflows/draft") class DraftWorkflowApi(Resource): @console_ns.doc("get_draft_workflow") @@ -475,14 +475,7 @@ def get(self, app_model: App): @console_ns.response( 200, "Draft workflow synced successfully", - console_ns.model( - "SyncDraftWorkflowResponse", - { - "result": fields.String, - "hash": fields.String, - "updated_at": fields.String, - }, - ), + console_ns.models[SyncDraftWorkflowResponse.__name__], ) @console_ns.response(400, "Invalid workflow configuration") @console_ns.response(403, "Permission denied") @@ -535,11 +528,11 @@ def post(self, current_user: Account, app_model: App): except VariableError as e: raise InvalidArgumentError(description=str(e)) - return { - "result": "success", - "hash": workflow.unique_hash, - "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), - } + return SyncDraftWorkflowResponse( + result="success", + hash=workflow.unique_hash, + updated_at=to_timestamp(workflow.updated_at or workflow.created_at), + ).model_dump(mode="json") @console_ns.route("/apps//advanced-chat/workflows/draft/run") @@ -548,7 +541,7 @@ class AdvancedChatDraftWorkflowRunApi(Resource): @console_ns.doc(description="Run draft workflow for advanced chat application") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[AdvancedChatWorkflowRunPayload.__name__]) - @console_ns.response(200, "Workflow run started successfully", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Workflow run started successfully") @console_ns.response(400, "Invalid request parameters") @console_ns.response(403, "Permission denied") @setup_required @@ -574,6 +567,7 @@ def post(self, current_user: Account, app_model: App): app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=True ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -594,11 +588,7 @@ class AdvancedChatDraftRunIterationNodeApi(Resource): @console_ns.doc(description="Run draft workflow iteration node for advanced chat") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models[IterationNodeRunPayload.__name__]) - @console_ns.response( - 200, - "Iteration node run started successfully", - console_ns.models[GeneratedAppResponse.__name__], - ) + @console_ns.response(200, "Iteration node run started successfully") @console_ns.response(403, "Permission denied") @console_ns.response(404, "Node not found") @setup_required @@ -619,6 +609,7 @@ def post(self, current_user: Account, app_model: App, node_id: str): app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -637,11 +628,7 @@ class WorkflowDraftRunIterationNodeApi(Resource): @console_ns.doc(description="Run draft workflow iteration node") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models[IterationNodeRunPayload.__name__]) - @console_ns.response( - 200, - "Workflow iteration node run started successfully", - console_ns.models[GeneratedAppResponse.__name__], - ) + @console_ns.response(200, "Workflow iteration node run started successfully") @console_ns.response(403, "Permission denied") @console_ns.response(404, "Node not found") @setup_required @@ -662,6 +649,7 @@ def post(self, current_user: Account, app_model: App, node_id: str): app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -680,7 +668,7 @@ class AdvancedChatDraftRunLoopNodeApi(Resource): @console_ns.doc(description="Run draft workflow loop node for advanced chat") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models[LoopNodeRunPayload.__name__]) - @console_ns.response(200, "Loop node run started successfully", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Loop node run started successfully") @console_ns.response(403, "Permission denied") @console_ns.response(404, "Node not found") @setup_required @@ -701,6 +689,7 @@ def post(self, current_user: Account, app_model: App, node_id: str): app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -719,11 +708,7 @@ class WorkflowDraftRunLoopNodeApi(Resource): @console_ns.doc(description="Run draft workflow loop node") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models[LoopNodeRunPayload.__name__]) - @console_ns.response( - 200, - "Workflow loop node run started successfully", - console_ns.models[GeneratedAppResponse.__name__], - ) + @console_ns.response(200, "Workflow loop node run started successfully") @console_ns.response(403, "Permission denied") @console_ns.response(404, "Node not found") @setup_required @@ -744,6 +729,7 @@ def post(self, current_user: Account, app_model: App, node_id: str): app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -764,10 +750,7 @@ class HumanInputFormPreviewPayload(BaseModel): class HumanInputFormSubmitPayload(BaseModel): - form_inputs: dict[str, Any] = Field( - ..., - description="Values the user provides for the form's own fields", - ) + form_inputs: dict[str, Any] = Field(..., description="Values the user provides for the form's own fields") inputs: dict[str, Any] = Field( ..., description="Values used to fill missing upstream variables referenced in form_content", @@ -797,7 +780,11 @@ class AdvancedChatDraftHumanInputFormPreviewApi(Resource): @console_ns.doc(description="Get human input form preview for advanced chat workflow") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models[HumanInputFormPreviewPayload.__name__]) - @console_ns.response(200, "Human input form preview", console_ns.models[HumanInputFormPreviewResponse.__name__]) + @console_ns.response( + 200, + "Human input form preview retrieved", + console_ns.models[HumanInputFormPreviewResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -819,7 +806,7 @@ def post(self, current_user: Account, app_model: App, node_id: str): node_id=node_id, inputs=inputs, ) - return jsonable_encoder(preview) + return dump_response(HumanInputFormPreviewResponse, preview) @console_ns.route("/apps//advanced-chat/workflows/draft/human-input/nodes//form/run") @@ -830,8 +817,7 @@ class AdvancedChatDraftHumanInputFormRunApi(Resource): @console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__]) @console_ns.response( 200, - "Human input form submission result", - console_ns.models[HumanInputFormSubmitResponse.__name__], + "Human input form submitted", ) @setup_required @login_required @@ -854,6 +840,7 @@ def post(self, current_user: Account, app_model: App, node_id: str): inputs=args.inputs, action=args.action, ) + # TODO: typing here for result return jsonable_encoder(result) @@ -863,7 +850,11 @@ class WorkflowDraftHumanInputFormPreviewApi(Resource): @console_ns.doc(description="Get human input form preview for workflow") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models[HumanInputFormPreviewPayload.__name__]) - @console_ns.response(200, "Human input form preview", console_ns.models[HumanInputFormPreviewResponse.__name__]) + @console_ns.response( + 200, + "Human input form preview retrieved", + console_ns.models[HumanInputFormPreviewResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -885,7 +876,7 @@ def post(self, current_user: Account, app_model: App, node_id: str): node_id=node_id, inputs=inputs, ) - return jsonable_encoder(preview) + return dump_response(HumanInputFormPreviewResponse, preview) @console_ns.route("/apps//workflows/draft/human-input/nodes//form/run") @@ -896,8 +887,7 @@ class WorkflowDraftHumanInputFormRunApi(Resource): @console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__]) @console_ns.response( 200, - "Human input form submission result", - console_ns.models[HumanInputFormSubmitResponse.__name__], + "Human input form submitted", ) @setup_required @login_required @@ -920,6 +910,7 @@ def post(self, current_user: Account, app_model: App, node_id: str): inputs=args.inputs, action=args.action, ) + # TODO: typing here return jsonable_encoder(result) @@ -929,7 +920,11 @@ class WorkflowDraftHumanInputDeliveryTestApi(Resource): @console_ns.doc(description="Test human input delivery for workflow") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models[HumanInputDeliveryTestPayload.__name__]) - @console_ns.response(200, "Human input delivery test result", console_ns.models[EmptyObjectResponse.__name__]) + @console_ns.response( + 200, + "Human input delivery tested", + console_ns.models[HumanInputDeliveryTestResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -950,7 +945,7 @@ def post(self, current_user: Account, app_model: App, node_id: str): delivery_method_id=args.delivery_method_id, inputs=args.inputs, ) - return jsonable_encoder({}) + return HumanInputDeliveryTestResponse().model_dump(mode="json") @console_ns.route("/apps//workflows/draft/run") @@ -959,11 +954,7 @@ class DraftWorkflowRunApi(Resource): @console_ns.doc(description="Run draft workflow") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[DraftWorkflowRunPayload.__name__]) - @console_ns.response( - 200, - "Draft workflow run started successfully", - console_ns.models[GeneratedAppResponse.__name__], - ) + @console_ns.response(200, "Draft workflow run started successfully") @console_ns.response(403, "Permission denied") @setup_required @login_required @@ -991,6 +982,7 @@ def post(self, current_user: Account, app_model: App): streaming=True, ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except InvokeRateLimitError as ex: raise InvokeRateLimitHttpError(ex.description) @@ -1021,7 +1013,7 @@ def post(self, app_model: App, task_id: str): # New graph engine command channel mechanism GraphEngineManager(redis_client).send_stop_command(task_id) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/apps//workflows/draft/nodes//run") @@ -1109,7 +1101,7 @@ def get(self, app_model: App): return dump_response(WorkflowResponse, workflow) @console_ns.expect(console_ns.models[PublishWorkflowPayload.__name__]) - @console_ns.response(200, "Workflow published successfully", console_ns.models[WorkflowPublishResponse.__name__]) + @console_ns.response(200, "Workflow published successfully", console_ns.models[PublishWorkflowResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -1141,12 +1133,9 @@ def post(self, current_user: Account, app_model: App): app_model_in_session.updated_by = current_user.id app_model_in_session.updated_at = naive_utc_now() - workflow_created_at = TimestampField().format(workflow.created_at) + workflow_created_at = to_timestamp(workflow.created_at) - return { - "result": "success", - "created_at": workflow_created_at, - } + return PublishWorkflowResponse(result="success", created_at=workflow_created_at).model_dump(mode="json") @console_ns.route("/apps//workflows/default-workflow-block-configs") @@ -1157,7 +1146,6 @@ class DefaultBlockConfigsApi(Resource): @console_ns.response( 200, "Default block configurations retrieved successfully", - console_ns.models[DefaultBlockConfigsResponse.__name__], ) @setup_required @login_required @@ -1179,13 +1167,12 @@ class DefaultBlockConfigApi(Resource): @console_ns.doc("get_default_block_config") @console_ns.doc(description="Get default block configuration by type") @console_ns.doc(params={"app_id": "Application ID", "block_type": "Block type"}) + @console_ns.doc(params=query_params_from_model(DefaultBlockConfigQuery)) @console_ns.response( 200, "Default block configuration retrieved successfully", - console_ns.models[DefaultBlockConfigResponse.__name__], ) @console_ns.response(404, "Block type not found") - @console_ns.doc(params=query_params_from_model(DefaultBlockConfigQuery)) @setup_required @login_required @account_initialization_required @@ -1279,15 +1266,14 @@ def post(self, current_user: Account, app_model: App): workflow_service = WorkflowService() workflow_service.update_draft_workflow_features(app_model=app_model, features=features, account=current_user) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/apps//workflows") class PublishedAllWorkflowApi(Resource): - @console_ns.doc(params=query_params_from_model(WorkflowListQuery)) @console_ns.doc("get_all_published_workflows") @console_ns.doc(description="Get all published workflows for an application") - @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(WorkflowListQuery)}) @console_ns.response( 200, "Published workflows retrieved successfully", @@ -1340,7 +1326,11 @@ class DraftWorkflowRestoreApi(Resource): @console_ns.doc("restore_workflow_to_draft") @console_ns.doc(description="Restore a published workflow version into the draft workflow") @console_ns.doc(params={"app_id": "Application ID", "workflow_id": "Published workflow ID"}) - @console_ns.response(200, "Workflow restored successfully", console_ns.models[WorkflowRestoreResponse.__name__]) + @console_ns.response( + 200, + "Workflow restored successfully", + console_ns.models[SyncDraftWorkflowResponse.__name__], + ) @console_ns.response(400, "Source workflow must be published") @console_ns.response(404, "Workflow not found") @setup_required @@ -1366,11 +1356,11 @@ def post(self, current_user: Account, app_model: App, workflow_id: str): except ValueError as exc: raise BadRequest(str(exc)) from exc - return { - "result": "success", - "hash": workflow.unique_hash, - "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), - } + return SyncDraftWorkflowResponse( + result="success", + hash=workflow.unique_hash, + updated_at=to_timestamp(workflow.updated_at or workflow.created_at), + ).model_dump(mode="json") @console_ns.route("/apps//workflows/") @@ -1422,6 +1412,7 @@ def patch(self, current_user: Account, app_model: App, workflow_id: str): return dump_response(WorkflowResponse, workflow) + @console_ns.response(204, "Workflow deleted successfully") @setup_required @login_required @account_initialization_required @@ -1480,7 +1471,7 @@ def get(self, app_model: App, node_id: str): ) if node_exec is None: raise NotFound("last run not found") - return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json") + return dump_response(WorkflowRunNodeExecutionResponse, node_exec) @console_ns.route("/apps//workflows/draft/trigger/run") @@ -1493,19 +1484,8 @@ class DraftWorkflowTriggerRunApi(Resource): @console_ns.doc("poll_draft_workflow_trigger_run") @console_ns.doc(description="Poll for trigger events and execute full workflow when event arrives") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect( - console_ns.model( - "DraftWorkflowTriggerRunRequest", - { - "node_id": fields.String(required=True, description="Node ID"), - }, - ) - ) - @console_ns.response( - 200, - "Trigger event received and workflow executed successfully", - console_ns.models[GeneratedAppResponse.__name__], - ) + @console_ns.expect(console_ns.models[DraftWorkflowTriggerRunPayload.__name__]) + @console_ns.response(200, "Trigger event received and workflow executed successfully") @console_ns.response(403, "Permission denied") @console_ns.response(500, "Internal server error") @setup_required @@ -1537,10 +1517,11 @@ def post(self, current_user: Account, app_model: App): try: event = poller.poll() if not event: - return jsonable_encoder({"status": "waiting", "retry_in": LISTENING_RETRY_IN}) + return _trigger_debug_waiting_response() workflow_args = dict(event.workflow_args) workflow_args[SKIP_PREPARE_USER_INPUTS_KEY] = True + # response-contract:ignore compact_generate_response return helper.compact_generate_response( AppGenerateService.generate( app_model=app_model, @@ -1554,7 +1535,7 @@ def post(self, current_user: Account, app_model: App): except InvokeRateLimitError as ex: raise InvokeRateLimitHttpError(ex.description) except PluginInvokeError as e: - return jsonable_encoder({"status": "error", "error": e.to_user_friendly_error()}), 400 + return _trigger_debug_error_response(e.to_user_friendly_error()), 400 except Exception as e: logger.exception("Error polling trigger debug event") raise e @@ -1573,7 +1554,7 @@ class DraftWorkflowTriggerNodeApi(Resource): @console_ns.response( 200, "Trigger event received and node executed successfully", - console_ns.models[GeneratedAppResponse.__name__], + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], ) @console_ns.response(403, "Permission denied") @console_ns.response(500, "Internal server error") @@ -1617,12 +1598,12 @@ def post(self, current_user: Account, app_model: App, node_id: str): ) event = poller.poll() except PluginInvokeError as e: - return jsonable_encoder({"status": "error", "error": e.to_user_friendly_error()}), 400 + return _trigger_debug_error_response(e.to_user_friendly_error()), 400 except Exception as e: logger.exception("Error polling trigger debug event") raise e if not event: - return jsonable_encoder({"status": "waiting", "retry_in": LISTENING_RETRY_IN}) + return _trigger_debug_waiting_response() raw_files = event.workflow_args.get("files") files = _parse_file(draft_workflow, raw_files if isinstance(raw_files, list) else None) @@ -1636,12 +1617,10 @@ def post(self, current_user: Account, app_model: App, node_id: str): query="", files=files, ) - return jsonable_encoder(node_execution) + return dump_response(WorkflowRunNodeExecutionResponse, node_execution) except Exception as e: logger.exception("Error running draft workflow trigger node") - return jsonable_encoder( - {"status": "error", "error": "An unexpected error occurred while running the node."} - ), 400 + return _trigger_debug_error_response("An unexpected error occurred while running the node."), 400 @console_ns.route("/apps//workflows/draft/trigger/run-all") @@ -1655,7 +1634,7 @@ class DraftWorkflowTriggerRunAllApi(Resource): @console_ns.doc(description="Full workflow debug when the start node is a trigger") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[DraftWorkflowTriggerRunAllPayload.__name__]) - @console_ns.response(200, "Workflow executed successfully", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Workflow executed successfully") @console_ns.response(403, "Permission denied") @console_ns.response(500, "Internal server error") @setup_required @@ -1685,12 +1664,12 @@ def post(self, current_user: Account, app_model: App): node_ids=node_ids, ) except PluginInvokeError as e: - return jsonable_encoder({"status": "error", "error": e.to_user_friendly_error()}), 400 + return _trigger_debug_error_response(e.to_user_friendly_error()), 400 except Exception as e: logger.exception("Error polling trigger debug event") raise e if trigger_debug_event is None: - return jsonable_encoder({"status": "waiting", "retry_in": LISTENING_RETRY_IN}) + return _trigger_debug_waiting_response() try: workflow_args = dict(trigger_debug_event.workflow_args) @@ -1704,16 +1683,13 @@ def post(self, current_user: Account, app_model: App): streaming=True, root_node_id=trigger_debug_event.node_id, ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except InvokeRateLimitError as ex: raise InvokeRateLimitHttpError(ex.description) except Exception: logger.exception("Error running draft workflow trigger run-all") - return jsonable_encoder( - { - "status": "error", - } - ), 400 + return _trigger_debug_error_response(), 400 @console_ns.route("/apps/workflows/online-users") @@ -1738,7 +1714,7 @@ def post(self, current_tenant_id: str): raise BadRequest(f"Maximum {MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS} app_ids are allowed per request.") if not app_ids: - return {"data": []} + return WorkflowOnlineUsersResponse(data=[]).model_dump(mode="json") workflow_service = WorkflowService() accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id) diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py index b3426e8f6ea119..92c70995b9e7c4 100644 --- a/api/controllers/console/app/workflow_app_log.py +++ b/api/controllers/console/app/workflow_app_log.py @@ -20,7 +20,7 @@ from extensions.ext_database import db from fields.base import ResponseModel from fields.end_user_fields import SimpleEndUser -from fields.member_fields import SimpleAccount +from fields.member_fields import SimpleAccountResponse from graphon.enums import WorkflowExecutionStatus from libs.helper import to_timestamp from libs.login import login_required @@ -115,7 +115,7 @@ class WorkflowAppLogPartialResponse(ResponseModel): details: Any = None created_from: str | None = None created_by_role: str | None = None - created_by_account: SimpleAccount | None = None + created_by_account: SimpleAccountResponse | None = None created_by_end_user: SimpleEndUser | None = None created_at: int | None = None @@ -129,7 +129,7 @@ class WorkflowArchivedLogPartialResponse(ResponseModel): id: str workflow_run: WorkflowRunForArchivedLogResponse | None = None trigger_metadata: Any = None - created_by_account: SimpleAccount | None = None + created_by_account: SimpleAccountResponse | None = None created_by_end_user: SimpleEndUser | None = None created_at: int | None = None diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py index c70f00dcfa1c1d..20c05d2952b3c5 100644 --- a/api/controllers/console/app/workflow_comment.py +++ b/api/controllers/console/app/workflow_comment.py @@ -2,7 +2,7 @@ from datetime import datetime from flask_restx import Resource -from pydantic import BaseModel, Field, TypeAdapter, computed_field, field_validator +from pydantic import BaseModel, Field, computed_field, field_validator from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns @@ -19,7 +19,7 @@ ) from extensions.ext_database import db from fields.base import ResponseModel -from fields.member_fields import AccountWithRole +from fields.member_fields import AccountWithRoleResponse from libs.helper import build_avatar_url, dump_response, to_timestamp from libs.login import login_required from models import Account, App @@ -52,7 +52,7 @@ class WorkflowCommentReplyPayload(BaseModel): class WorkflowCommentMentionUsersPayload(BaseModel): - users: list[AccountWithRole] + users: list[AccountWithRoleResponse] class WorkflowCommentAccount(ResponseModel): @@ -189,7 +189,7 @@ def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: register_schema_models( console_ns, - AccountWithRole, + AccountWithRoleResponse, WorkflowCommentMentionUsersPayload, WorkflowCommentCreatePayload, WorkflowCommentUpdatePayload, @@ -491,6 +491,4 @@ def get(self, current_user: Account, app_model: App): if current_tenant is None: raise ValueError("current tenant is required") members = TenantService.get_tenant_members(current_tenant, session=db.session) - users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True) - response = WorkflowCommentMentionUsersPayload(users=users) - return response.model_dump(mode="json"), 200 + return dump_response(WorkflowCommentMentionUsersPayload, {"users": members}), 200 diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index ff82572b87eae9..5811b3b217ae8b 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -1,11 +1,11 @@ import logging from collections.abc import Callable from functools import wraps -from typing import Any, Concatenate, TypedDict, override +from typing import Any, Concatenate, Self, override from uuid import UUID from flask import Response, request -from flask_restx import Resource, fields, marshal, marshal_with +from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy.orm import sessionmaker @@ -47,28 +47,6 @@ _file_access_controller = DatabaseFileAccessController() -class OpaqueRawField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return {"type": "object"} - - -class JsonValueRawField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return { - "anyOf": [ - {"type": "string"}, - {"type": "integer"}, - {"type": "number"}, - {"type": "boolean"}, - {"type": "object", "additionalProperties": True}, - {"type": "array", "items": {}}, - {"type": "null"}, - ] - } - - class WorkflowDraftVariableListQuery(BaseModel): page: int = Field(default=1, ge=1, le=100_000, description="Page number") limit: int = Field(default=20, ge=1, le=100, description="Items per page") @@ -76,24 +54,111 @@ class WorkflowDraftVariableListQuery(BaseModel): class WorkflowDraftVariableUpdatePayload(BaseModel): name: str | None = Field(default=None, description="Variable name") - value: Any | None = Field(default=None, description="Variable value") + value: Any = Field(default=None, description="Variable value") class ConversationVariableUpdatePayload(BaseModel): conversation_variables: list[dict[str, Any]] = Field( - ..., - description="Conversation variables for the draft workflow", + ..., description="Conversation variables for the draft workflow" ) class EnvironmentVariableUpdatePayload(BaseModel): - environment_variables: list[dict[str, Any]] = Field( - ..., - description="Environment variables for the draft workflow", - ) + environment_variables: list[dict[str, Any]] = Field(..., description="Environment variables for the draft workflow") + + +class WorkflowDraftVariableFullContentResponse(ResponseModel): + size_bytes: int | None + value_type: str + length: int | None + download_url: str + + @classmethod + def from_workflow_draft_variable(cls, variable: WorkflowDraftVariable) -> Self | None: + if not variable.is_truncated(): + return None + + variable_file = variable.variable_file + assert variable_file is not None + + return cls( + size_bytes=variable_file.size, + value_type=str(variable_file.value_type.exposed_type()), + length=variable_file.length, + download_url=file_helpers.get_signed_file_url(variable_file.upload_file_id, as_attachment=True), + ) + + +class WorkflowDraftVariableWithoutValueResponse(ResponseModel): + id: str + type: str + name: str + description: str + selector: list[str] + value_type: str + edited: bool + visible: bool + is_truncated: bool + + @classmethod + def from_workflow_draft_variable(cls, variable: WorkflowDraftVariable) -> Self: + return cls( + id=variable.id, + type=variable.get_variable_type().value, + name=variable.name, + description=variable.description, + selector=variable.get_selector(), + value_type=_serialize_variable_type(variable), + edited=variable.edited, + visible=variable.visible, + is_truncated=variable.file_id is not None, + ) + + +class WorkflowDraftVariableResponse(WorkflowDraftVariableWithoutValueResponse): + value: Any + full_content: WorkflowDraftVariableFullContentResponse | None + + @classmethod + @override + def from_workflow_draft_variable(cls, variable: WorkflowDraftVariable) -> Self: + without_value = WorkflowDraftVariableWithoutValueResponse.from_workflow_draft_variable(variable) + return cls( + **without_value.model_dump(), + value=_serialize_var_value(variable), + full_content=WorkflowDraftVariableFullContentResponse.from_workflow_draft_variable(variable), + ) + + +class WorkflowDraftVariableListWithoutValueResponse(ResponseModel): + items: list[WorkflowDraftVariableWithoutValueResponse] + total: int | None + + @classmethod + def from_workflow_draft_variable_list(cls, variable_list: WorkflowDraftVariableList) -> Self: + return cls( + items=[ + WorkflowDraftVariableWithoutValueResponse.from_workflow_draft_variable(variable) + for variable in variable_list.variables + ], + total=variable_list.total, + ) -class EnvironmentVariableItemResponse(ResponseModel): +class WorkflowDraftVariableListResponse(ResponseModel): + items: list[WorkflowDraftVariableResponse] + + @classmethod + def from_workflow_draft_variable_list(cls, variable_list: WorkflowDraftVariableList) -> Self: + return cls( + items=[ + WorkflowDraftVariableResponse.from_workflow_draft_variable(variable) + for variable in variable_list.variables + ], + ) + + +class WorkflowDraftEnvironmentVariableResponse(ResponseModel): id: str type: str name: str @@ -106,8 +171,8 @@ class EnvironmentVariableItemResponse(ResponseModel): editable: bool -class EnvironmentVariableListResponse(ResponseModel): - items: list[EnvironmentVariableItemResponse] +class WorkflowDraftEnvironmentVariableListResponse(ResponseModel): + items: list[WorkflowDraftEnvironmentVariableResponse] register_schema_models( @@ -117,22 +182,33 @@ class EnvironmentVariableListResponse(ResponseModel): ConversationVariableUpdatePayload, EnvironmentVariableUpdatePayload, ) -register_response_schema_models(console_ns, SimpleResultResponse, EnvironmentVariableListResponse) + +register_response_schema_models( + console_ns, + WorkflowDraftVariableFullContentResponse, + WorkflowDraftVariableWithoutValueResponse, + WorkflowDraftVariableResponse, + WorkflowDraftVariableListWithoutValueResponse, + WorkflowDraftVariableListResponse, + WorkflowDraftEnvironmentVariableResponse, + WorkflowDraftEnvironmentVariableListResponse, + SimpleResultResponse, +) -def _convert_values_to_json_serializable_object(value: Segment): +def _convert_values_to_json_serializable_object(value: Segment) -> Any: match value: case FileSegment(): return value.value.model_dump() case ArrayFileSegment(): - return [i.model_dump() for i in value.value] + return [file.model_dump() for file in value.value] case SegmentGroup(): return [_convert_values_to_json_serializable_object(i) for i in value.value] case _: return value.value -def _serialize_var_value(variable: WorkflowDraftVariable): +def _serialize_var_value(variable: WorkflowDraftVariable) -> Any: value = variable.get_value() # create a copy of the value to avoid affecting the model cache. value = value.model_copy(deep=True) @@ -153,30 +229,6 @@ def _serialize_variable_type(workflow_draft_var: WorkflowDraftVariable) -> str: return str(value_type.exposed_type()) -class FullContentDict(TypedDict): - size_bytes: int | None - value_type: str - length: int | None - download_url: str - - -def _serialize_full_content(variable: WorkflowDraftVariable) -> FullContentDict | None: - """Serialize full_content information for large variables.""" - if not variable.is_truncated(): - return None - - variable_file = variable.variable_file - assert variable_file is not None - - result: FullContentDict = { - "size_bytes": variable_file.size, - "value_type": str(variable_file.value_type.exposed_type()), - "length": variable_file.length, - "download_url": file_helpers.get_signed_file_url(variable_file.upload_file_id, as_attachment=True), - } - return result - - def ensure_variable_access( variable: WorkflowDraftVariable | None, app_id: str, @@ -190,83 +242,21 @@ def ensure_variable_access( return variable -_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS = { - "id": fields.String, - "type": fields.String(attribute=lambda model: model.get_variable_type()), - "name": fields.String, - "description": fields.String, - "selector": fields.List(fields.String, attribute=lambda model: model.get_selector()), - "value_type": fields.String(attribute=_serialize_variable_type), - "edited": fields.Boolean(attribute=lambda model: model.edited), - "visible": fields.Boolean, - "is_truncated": fields.Boolean(attribute=lambda model: model.file_id is not None), -} - -_WORKFLOW_DRAFT_VARIABLE_FIELDS = { - **_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS, - "value": JsonValueRawField(attribute=_serialize_var_value), - "full_content": OpaqueRawField(attribute=_serialize_full_content), -} - -_WORKFLOW_DRAFT_ENV_VARIABLE_FIELDS = { - "id": fields.String, - "type": fields.String(attribute=lambda _: "env"), - "name": fields.String, - "description": fields.String, - "selector": fields.List(fields.String, attribute=lambda model: model.get_selector()), - "value_type": fields.String(attribute=_serialize_variable_type), - "edited": fields.Boolean(attribute=lambda model: model.edited), - "visible": fields.Boolean, -} - -_WORKFLOW_DRAFT_ENV_VARIABLE_LIST_FIELDS = { - "items": fields.List(fields.Nested(_WORKFLOW_DRAFT_ENV_VARIABLE_FIELDS)), -} - - -def _get_items(var_list: WorkflowDraftVariableList) -> list[WorkflowDraftVariable]: - return var_list.variables - - -_WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS = { - "items": fields.List(fields.Nested(_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS), attribute=_get_items), - "total": fields.Integer, -} - -_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS = { - "items": fields.List(fields.Nested(_WORKFLOW_DRAFT_VARIABLE_FIELDS), attribute=_get_items), -} - -# Register models for flask_restx to avoid dict type issues in Swagger -workflow_draft_variable_without_value_model = console_ns.model( - "WorkflowDraftVariableWithoutValue", _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS -) - -workflow_draft_variable_model = console_ns.model("WorkflowDraftVariable", _WORKFLOW_DRAFT_VARIABLE_FIELDS) - -workflow_draft_env_variable_model = console_ns.model("WorkflowDraftEnvVariable", _WORKFLOW_DRAFT_ENV_VARIABLE_FIELDS) - -workflow_draft_env_variable_list_fields_copy = _WORKFLOW_DRAFT_ENV_VARIABLE_LIST_FIELDS.copy() -workflow_draft_env_variable_list_fields_copy["items"] = fields.List(fields.Nested(workflow_draft_env_variable_model)) -workflow_draft_env_variable_list_model = console_ns.model( - "WorkflowDraftEnvVariableList", workflow_draft_env_variable_list_fields_copy -) - -workflow_draft_variable_list_without_value_fields_copy = _WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS.copy() -workflow_draft_variable_list_without_value_fields_copy["items"] = fields.List( - fields.Nested(workflow_draft_variable_without_value_model), attribute=_get_items -) -workflow_draft_variable_list_without_value_model = console_ns.model( - "WorkflowDraftVariableListWithoutValue", workflow_draft_variable_list_without_value_fields_copy -) +def validate_node_id(node_id: str) -> None: + if node_id in [ + CONVERSATION_VARIABLE_NODE_ID, + SYSTEM_VARIABLE_NODE_ID, + ]: + # NOTE(QuantumGhost): While we store the system and conversation variables as node variables + # with specific `node_id` in database, we still want to make the API separated. By disallowing + # accessing system and conversation variables in `WorkflowDraftNodeVariableListApi`, + # we mitigate the risk that user of the API depending on the implementation detail of the API. + # + # ref: [Hyrum's Law](https://www.hyrumslaw.com/) -workflow_draft_variable_list_fields_copy = _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS.copy() -workflow_draft_variable_list_fields_copy["items"] = fields.List( - fields.Nested(workflow_draft_variable_model), attribute=_get_items -) -workflow_draft_variable_list_model = console_ns.model( - "WorkflowDraftVariableList", workflow_draft_variable_list_fields_copy -) + raise InvalidArgumentError( + f"invalid node_id, please use correspond api for conversation and system variables, node_id={node_id}", + ) def _api_prerequisite[T, **P, R]( @@ -296,23 +286,42 @@ def wrapper(self: T, current_user: Account, *args: P.args, **kwargs: P.kwargs) - return wrapper +def _get_variable_list(app_model: App, node_id: str, current_user_id: str) -> WorkflowDraftVariableList: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: + draft_var_srv = WorkflowDraftVariableService( + session=session, + ) + if node_id == CONVERSATION_VARIABLE_NODE_ID: + draft_vars = draft_var_srv.list_conversation_variables(app_model.id, user_id=current_user_id) + elif node_id == SYSTEM_VARIABLE_NODE_ID: + draft_vars = draft_var_srv.list_system_variables(app_model.id, user_id=current_user_id) + else: + draft_vars = draft_var_srv.list_node_variables( + app_id=app_model.id, + node_id=node_id, + user_id=current_user_id, + ) + return draft_vars + + @console_ns.route("/apps//workflows/draft/variables") class WorkflowVariableCollectionApi(Resource): - @console_ns.doc(params=query_params_from_model(WorkflowDraftVariableListQuery)) @console_ns.doc("get_workflow_variables") @console_ns.doc(description="Get draft workflow variables") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params={"page": "Page number (1-100000)", "limit": "Number of items per page (1-100)"}) + @console_ns.doc(params=query_params_from_model(WorkflowDraftVariableListQuery)) @console_ns.response( - 200, "Workflow variables retrieved successfully", workflow_draft_variable_list_without_value_model + 200, + "Workflow variables retrieved successfully", + console_ns.models[WorkflowDraftVariableListWithoutValueResponse.__name__], ) @_api_prerequisite - @marshal_with(workflow_draft_variable_list_without_value_model) @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App): """ Get draft workflow """ + # response-contract:ignore constructed Pydantic response args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # fetch draft workflow by app_model @@ -333,7 +342,9 @@ def get(self, current_user: Account, app_model: App): user_id=current_user.id, ) - return workflow_vars + return WorkflowDraftVariableListWithoutValueResponse.from_workflow_draft_variable_list( + workflow_vars + ).model_dump(mode="json") @console_ns.doc("delete_workflow_variables") @console_ns.doc(description="Delete all draft workflow variables") @@ -345,24 +356,7 @@ def delete(self, current_user: Account, app_model: App): ) draft_var_srv.delete_user_workflow_variables(app_model.id, user_id=current_user.id) db.session.commit() - return Response("", 204) - - -def validate_node_id(node_id: str) -> None: - if node_id in [ - CONVERSATION_VARIABLE_NODE_ID, - SYSTEM_VARIABLE_NODE_ID, - ]: - # NOTE(QuantumGhost): While we store the system and conversation variables as node variables - # with specific `node_id` in database, we still want to make the API separated. By disallowing - # accessing system and conversation variables in `WorkflowDraftNodeVariableListApi`, - # we mitigate the risk that user of the API depending on the implementation detail of the API. - # - # ref: [Hyrum's Law](https://www.hyrumslaw.com/) - - raise InvalidArgumentError( - f"invalid node_id, please use correspond api for conversation and system variables, node_id={node_id}", - ) + return "", 204 @console_ns.route("/apps//workflows/draft/nodes//variables") @@ -370,11 +364,15 @@ class NodeVariableCollectionApi(Resource): @console_ns.doc("get_node_variables") @console_ns.doc(description="Get variables for a specific node") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) - @console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model) + @console_ns.response( + 200, + "Node variables retrieved successfully", + console_ns.models[WorkflowDraftVariableListResponse.__name__], + ) @_api_prerequisite - @marshal_with(workflow_draft_variable_list_model) @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App, node_id: str): + # response-contract:ignore constructed Pydantic response validate_node_id(node_id) with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: draft_var_srv = WorkflowDraftVariableService( @@ -382,7 +380,7 @@ def get(self, current_user: Account, app_model: App, node_id: str): ) node_vars = draft_var_srv.list_node_variables(app_model.id, node_id, user_id=current_user.id) - return node_vars + return WorkflowDraftVariableListResponse.from_workflow_draft_variable_list(node_vars).model_dump(mode="json") @console_ns.doc("delete_node_variables") @console_ns.doc(description="Delete all variables for a specific node") @@ -393,7 +391,7 @@ def delete(self, current_user: Account, app_model: App, node_id: str): srv = WorkflowDraftVariableService(db.session()) srv.delete_node_variables(app_model.id, node_id, user_id=current_user.id) db.session.commit() - return Response("", 204) + return "", 204 @console_ns.route("/apps//workflows/draft/variables/") @@ -404,12 +402,14 @@ class VariableApi(Resource): @console_ns.doc("get_variable") @console_ns.doc(description="Get a specific workflow variable") @console_ns.doc(params={"app_id": "Application ID", "variable_id": "Variable ID"}) - @console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model) + @console_ns.response( + 200, "Variable retrieved successfully", console_ns.models[WorkflowDraftVariableResponse.__name__] + ) @console_ns.response(404, "Variable not found") @_api_prerequisite - @marshal_with(workflow_draft_variable_model) @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App, variable_id: UUID): + # response-contract:ignore constructed Pydantic response draft_var_srv = WorkflowDraftVariableService( session=db.session(), ) @@ -420,16 +420,18 @@ def get(self, current_user: Account, app_model: App, variable_id: UUID): variable_id=variable_id_str, current_user_id=current_user.id, ) - return variable + return WorkflowDraftVariableResponse.from_workflow_draft_variable(variable).model_dump(mode="json") @console_ns.doc("update_variable") @console_ns.doc(description="Update a workflow variable") @console_ns.expect(console_ns.models[WorkflowDraftVariableUpdatePayload.__name__]) - @console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model) + @console_ns.response( + 200, "Variable updated successfully", console_ns.models[WorkflowDraftVariableResponse.__name__] + ) @console_ns.response(404, "Variable not found") @_api_prerequisite - @marshal_with(workflow_draft_variable_model) def patch(self, current_user: Account, app_model: App, variable_id: UUID): + # response-contract:ignore constructed Pydantic response # Request payload for file types: # # Local File: @@ -467,15 +469,16 @@ def patch(self, current_user: Account, app_model: App, variable_id: UUID): new_name = args_model.name raw_value = args_model.value if new_name is None and raw_value is None: - return variable + return WorkflowDraftVariableResponse.from_workflow_draft_variable(variable).model_dump(mode="json") new_value = None if raw_value is not None: + new_value_input: Any match variable.value_type: case SegmentType.FILE: if not isinstance(raw_value, dict): raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") - raw_value = build_from_mapping( + new_value_input = build_from_mapping( mapping=raw_value, tenant_id=app_model.tenant_id, access_controller=_file_access_controller, @@ -483,19 +486,22 @@ def patch(self, current_user: Account, app_model: App, variable_id: UUID): case SegmentType.ARRAY_FILE: if not isinstance(raw_value, list): raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") - if len(raw_value) > 0 and not isinstance(raw_value[0], dict): - raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") - raw_value = build_from_mappings( + for index, item in enumerate(raw_value): + if not isinstance(item, dict): + raise InvalidArgumentError( + description=f"expected dict for files[{index}], got {type(item)}" + ) + new_value_input = build_from_mappings( mappings=raw_value, tenant_id=app_model.tenant_id, access_controller=_file_access_controller, ) case _: - pass - new_value = build_segment_with_type(variable.value_type, raw_value) + new_value_input = raw_value + new_value = build_segment_with_type(variable.value_type, new_value_input) draft_var_srv.update_variable(variable, name=new_name, value=new_value) db.session.commit() - return variable + return WorkflowDraftVariableResponse.from_workflow_draft_variable(variable).model_dump(mode="json") @console_ns.doc("delete_variable") @console_ns.doc(description="Delete a workflow variable") @@ -515,7 +521,7 @@ def delete(self, current_user: Account, app_model: App, variable_id: UUID): ) draft_var_srv.delete_variable(variable) db.session.commit() - return Response("", 204) + return "", 204 @console_ns.route("/apps//workflows/draft/variables//reset") @@ -523,11 +529,12 @@ class VariableResetApi(Resource): @console_ns.doc("reset_variable") @console_ns.doc(description="Reset a workflow variable to its default value") @console_ns.doc(params={"app_id": "Application ID", "variable_id": "Variable ID"}) - @console_ns.response(200, "Variable reset successfully", workflow_draft_variable_model) + @console_ns.response(200, "Variable reset successfully", console_ns.models[WorkflowDraftVariableResponse.__name__]) @console_ns.response(204, "Variable reset (no content)") @console_ns.response(404, "Variable not found") @_api_prerequisite def put(self, current_user: Account, app_model: App, variable_id: UUID): + # response-contract:ignore constructed Pydantic response draft_var_srv = WorkflowDraftVariableService( session=db.session(), ) @@ -549,27 +556,8 @@ def put(self, current_user: Account, app_model: App, variable_id: UUID): resetted = draft_var_srv.reset_variable(draft_workflow, variable) db.session.commit() if resetted is None: - return Response("", 204) - else: - return marshal(resetted, workflow_draft_variable_model) - - -def _get_variable_list(app_model: App, node_id: str, current_user_id: str) -> WorkflowDraftVariableList: - with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: - draft_var_srv = WorkflowDraftVariableService( - session=session, - ) - if node_id == CONVERSATION_VARIABLE_NODE_ID: - draft_vars = draft_var_srv.list_conversation_variables(app_model.id, user_id=current_user_id) - elif node_id == SYSTEM_VARIABLE_NODE_ID: - draft_vars = draft_var_srv.list_system_variables(app_model.id, user_id=current_user_id) - else: - draft_vars = draft_var_srv.list_node_variables( - app_id=app_model.id, - node_id=node_id, - user_id=current_user_id, - ) - return draft_vars + return "", 204 + return WorkflowDraftVariableResponse.from_workflow_draft_variable(resetted).model_dump(mode="json") @console_ns.route("/apps//workflows/draft/conversation-variables") @@ -577,12 +565,16 @@ class ConversationVariableCollectionApi(Resource): @console_ns.doc("get_conversation_variables") @console_ns.doc(description="Get conversation variables for workflow") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Conversation variables retrieved successfully", workflow_draft_variable_list_model) + @console_ns.response( + 200, + "Conversation variables retrieved successfully", + console_ns.models[WorkflowDraftVariableListResponse.__name__], + ) @console_ns.response(404, "Draft workflow not found") @_api_prerequisite - @marshal_with(workflow_draft_variable_list_model) @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App): + # response-contract:ignore constructed Pydantic response # NOTE(QuantumGhost): Prefill conversation variables into the draft variables table # so their IDs can be returned to the caller. workflow_srv = WorkflowService() @@ -592,7 +584,9 @@ def get(self, current_user: Account, app_model: App): draft_var_srv = WorkflowDraftVariableService(db.session()) draft_var_srv.prefill_conversation_variable_default_values(draft_workflow, user_id=current_user.id) db.session.commit() - return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID, current_user.id) + return WorkflowDraftVariableListResponse.from_workflow_draft_variable_list( + _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID, current_user.id) + ).model_dump(mode="json") @console_ns.expect(console_ns.models[ConversationVariableUpdatePayload.__name__]) @console_ns.doc("update_conversation_variables") @@ -626,7 +620,7 @@ def post(self, current_user: Account, app_model: App): conversation_variables=conversation_variables, ) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/apps//workflows/draft/system-variables") @@ -634,12 +628,18 @@ class SystemVariableCollectionApi(Resource): @console_ns.doc("get_system_variables") @console_ns.doc(description="Get system variables for workflow") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model) + @console_ns.response( + 200, + "System variables retrieved successfully", + console_ns.models[WorkflowDraftVariableListResponse.__name__], + ) @_api_prerequisite - @marshal_with(workflow_draft_variable_list_model) @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App): - return _get_variable_list(app_model, SYSTEM_VARIABLE_NODE_ID, current_user.id) + # response-contract:ignore constructed Pydantic response + return WorkflowDraftVariableListResponse.from_workflow_draft_variable_list( + _get_variable_list(app_model, SYSTEM_VARIABLE_NODE_ID, current_user.id) + ).model_dump(mode="json") @console_ns.route("/apps//workflows/draft/environment-variables") @@ -650,7 +650,7 @@ class EnvironmentVariableCollectionApi(Resource): @console_ns.response( 200, "Environment variables retrieved successfully", - console_ns.models[EnvironmentVariableListResponse.__name__], + console_ns.models[WorkflowDraftEnvironmentVariableListResponse.__name__], ) @console_ns.response(404, "Draft workflow not found") @_api_prerequisite @@ -669,22 +669,22 @@ def get(self, _current_user: Account, app_model: App): env_vars_list = [] for v in env_vars: env_vars_list.append( - { - "id": v.id, - "type": "env", - "name": v.name, - "description": v.description, - "selector": v.selector, - "value_type": str(v.value_type.exposed_type()), - "value": v.value, + WorkflowDraftEnvironmentVariableResponse( + id=v.id, + type="env", + name=v.name, + description=v.description, + selector=list(v.selector), + value_type=str(v.value_type.exposed_type()), + value=v.value, # Do not track edited for env vars. - "edited": False, - "visible": True, - "editable": True, - } + edited=False, + visible=True, + editable=True, + ) ) - return {"items": env_vars_list} + return WorkflowDraftEnvironmentVariableListResponse(items=env_vars_list).model_dump(mode="json") @console_ns.expect(console_ns.models[EnvironmentVariableUpdatePayload.__name__]) @console_ns.doc("update_environment_variables") @@ -718,4 +718,4 @@ def post(self, current_user: Account, app_model: App): environment_variables=environment_variables, ) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") diff --git a/api/controllers/console/app/workflow_run.py b/api/controllers/console/app/workflow_run.py index 374537229ca0f1..520aa374f7b03e 100644 --- a/api/controllers/console/app/workflow_run.py +++ b/api/controllers/console/app/workflow_run.py @@ -37,7 +37,7 @@ from graphon.enums import WorkflowExecutionStatus from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage from libs.custom_inputs import time_duration -from libs.helper import uuid_value +from libs.helper import dump_response, uuid_value from libs.login import login_required from models import Account, App, AppMode, WorkflowArchiveLog, WorkflowRunTriggeredFrom from models.workflow import WorkflowRun @@ -183,9 +183,7 @@ def get(self, app_model: App): app_model=app_model, args=args, triggered_from=triggered_from ) - return AdvancedChatWorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump( - mode="json" - ) + return dump_response(AdvancedChatWorkflowRunPaginationResponse, result) @console_ns.route("/apps//workflow-runs//export") @@ -232,14 +230,9 @@ def get(self, app_model: App, run_id: UUID): expires_in=EXPORT_SIGNED_URL_EXPIRE_SECONDS, ) expires_at = datetime.now(UTC) + timedelta(seconds=EXPORT_SIGNED_URL_EXPIRE_SECONDS) - response = WorkflowRunExportResponse.model_validate( - { - "status": "success", - "presigned_url": presigned_url, - "presigned_url_expires_at": expires_at.isoformat(), - } - ) - return response.model_dump(mode="json"), 200 + return WorkflowRunExportResponse( + status="success", presigned_url=presigned_url, presigned_url_expires_at=expires_at.isoformat() + ).model_dump(mode="json"), 200 @console_ns.route("/apps//advanced-chat/workflow-runs/count") @@ -322,7 +315,7 @@ def get(self, app_model: App): app_model=app_model, args=args, triggered_from=triggered_from ) - return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json") + return dump_response(WorkflowRunPaginationResponse, result) @console_ns.route("/apps//workflow-runs/count") @@ -393,7 +386,7 @@ def get(self, app_model: App, run_id: UUID): if workflow_run is None: raise NotFoundError("Workflow run not found") - return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json") + return dump_response(WorkflowRunDetailResponse, workflow_run) @console_ns.route("/apps//workflow-runs//node-executions") @@ -471,8 +464,7 @@ def get(self, current_tenant_id: str, workflow_run_id: str): # Check if workflow is suspended is_paused = workflow_run.status == WorkflowExecutionStatus.PAUSED if not is_paused: - empty_response = WorkflowPauseDetailsResponse(paused_at=None, paused_nodes=[]) - return empty_response.model_dump(mode="json"), 200 + return WorkflowPauseDetailsResponse(paused_at=None, paused_nodes=[]).model_dump(mode="json"), 200 pause_entity = workflow_run_repo.get_workflow_pause(workflow_run_id) pause_reasons = pause_entity.get_pause_reasons() if pause_entity else [] @@ -500,8 +492,6 @@ def get(self, current_tenant_id: str, workflow_run_id: str): else: raise AssertionError("unimplemented.") - response = WorkflowPauseDetailsResponse( - paused_at=paused_at.isoformat() + "Z" if paused_at else None, - paused_nodes=paused_nodes, - ) - return response.model_dump(mode="json"), 200 + return WorkflowPauseDetailsResponse( + paused_at=paused_at.isoformat() + "Z" if paused_at else None, paused_nodes=paused_nodes + ).model_dump(mode="json"), 200 diff --git a/api/controllers/console/app/workflow_trigger.py b/api/controllers/console/app/workflow_trigger.py index 8bd41b3e4290be..2f45d637256188 100644 --- a/api/controllers/console/app/workflow_trigger.py +++ b/api/controllers/console/app/workflow_trigger.py @@ -12,6 +12,7 @@ from controllers.common.schema import query_params_from_model, register_schema_models from extensions.ext_database import db from fields.base import ResponseModel +from libs.helper import dump_response from libs.login import login_required from models.enums import AppTriggerStatus from models.model import App, AppMode @@ -121,7 +122,7 @@ def get(self, app_model: App): if not webhook_trigger: raise NotFound("Webhook trigger not found for this node") - return WebhookTriggerResponse.model_validate(webhook_trigger, from_attributes=True).model_dump(mode="json") + return dump_response(WebhookTriggerResponse, webhook_trigger) @console_ns.route("/apps//triggers") @@ -160,9 +161,7 @@ def get(self, current_tenant_id: str, app_model: App): else: trigger.icon = "" # type: ignore - return WorkflowTriggerListResponse.model_validate({"data": triggers}, from_attributes=True).model_dump( - mode="json" - ) + return dump_response(WorkflowTriggerListResponse, {"data": triggers}) @console_ns.route("/apps//trigger-enable") @@ -204,4 +203,4 @@ def post(self, current_tenant_id: str, app_model: App): else: trigger.icon = "" # type: ignore - return WorkflowTriggerResponse.model_validate(trigger, from_attributes=True).model_dump(mode="json") + return dump_response(WorkflowTriggerResponse, trigger) diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 81f9ee4bae4c8b..74d1ecc38f778a 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -9,7 +9,12 @@ import services from configs import dify_config from constants.languages import get_valid_language -from controllers.common.fields import SimpleResultDataResponse, SimpleResultOptionalDataResponse, SimpleResultResponse +from controllers.common.fields import ( + SimpleResultDataResponse, + SimpleResultMessageResponse, + SimpleResultOptionalDataResponse, + SimpleResultResponse, +) from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.auth.error import ( @@ -87,6 +92,7 @@ def validate_timezone(cls, value: str | None) -> str | None: register_response_schema_models( console_ns, SimpleResultDataResponse, + SimpleResultMessageResponse, SimpleResultOptionalDataResponse, SimpleResultResponse, ) @@ -154,16 +160,19 @@ def post(self): if system_features.is_allow_create_workspace and not system_features.license.workspaces.is_available(): raise WorkspacesLimitExceeded() else: - return { - "result": "fail", - "data": "workspace not found, please contact system admin to invite you to join in a workspace", - } + return SimpleResultOptionalDataResponse( + result="fail", + data="workspace not found, please contact system admin to invite you to join in a workspace", + ).model_dump(mode="json") token_pair = AccountService.login(account=account, session=db.session, ip_address=extract_remote_ip(request)) AccountService.reset_login_error_rate_limit(normalized_email) # Create response with cookies instead of returning tokens in body - response = make_response({"result": "success"}) + # response-contract:ignore cookie-bearing Flask response + response = make_response( + SimpleResultOptionalDataResponse(result="success").model_dump(mode="json", exclude_none=True) + ) set_access_token_to_cookie(request, response, token_pair.access_token) set_refresh_token_to_cookie(request, response, token_pair.refresh_token) @@ -178,12 +187,11 @@ class LogoutApi(Resource): @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @with_current_user def post(self, account: Account): - if isinstance(account, flask_login.AnonymousUserMixin): - response = make_response({"result": "success"}) - else: + # response-contract:ignore cookie-bearing Flask response + response = make_response(SimpleResultResponse(result="success").model_dump(mode="json")) + if not isinstance(account, flask_login.AnonymousUserMixin): AccountService.logout(account=account) flask_login.logout_user() - response = make_response({"result": "success"}) # Clear cookies on logout clear_access_token_from_cookie(response) @@ -219,7 +227,7 @@ def post(self): is_allow_register=FeatureService.get_system_features().is_allow_register, ) - return {"result": "success", "data": token} + return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json") @console_ns.route("/email-code-login") @@ -252,7 +260,7 @@ def post(self): else: token = AccountService.send_email_code_login_email(account=account, language=language) - return {"result": "success", "data": token} + return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json") @console_ns.route("/email-code-login/validity") @@ -326,7 +334,8 @@ def post(self): AccountService.reset_login_error_rate_limit(user_email) # Create response with cookies instead of returning tokens in body - response = make_response({"result": "success"}) + # response-contract:ignore cookie-bearing Flask response + response = make_response(SimpleResultResponse(result="success").model_dump(mode="json")) set_csrf_token_to_cookie(request, response, token_pair.csrf_token) # Set HTTP-only secure cookies for tokens @@ -338,18 +347,22 @@ def post(self): @console_ns.route("/refresh-token") class RefreshTokenApi(Resource): @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(401, "Unauthorized", console_ns.models[SimpleResultMessageResponse.__name__]) def post(self): # Get refresh token from cookie instead of request body refresh_token = extract_refresh_token(request) if not refresh_token: - return {"result": "fail", "message": "No refresh token provided"}, 401 + return SimpleResultMessageResponse(result="fail", message="No refresh token provided").model_dump( + mode="json" + ), 401 try: new_token_pair = AccountService.refresh_token(refresh_token, session=db.session) # Create response with new cookies - response = make_response({"result": "success"}) + # response-contract:ignore cookie-bearing Flask response + response = make_response(SimpleResultResponse(result="success").model_dump(mode="json")) # Update cookies with new tokens set_csrf_token_to_cookie(request, response, new_token_pair.csrf_token) @@ -357,7 +370,7 @@ def post(self): set_refresh_token_to_cookie(request, response, new_token_pair.refresh_token) return response except Exception as e: - return {"result": "fail", "message": str(e)}, 401 + return SimpleResultMessageResponse(result="fail", message=str(e)).model_dump(mode="json"), 401 def _get_account_with_case_fallback(email: str): diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index 55bc85483d51c6..6303ede2f93fda 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -28,6 +28,7 @@ with_current_tenant_id, with_current_user, ) +from core.entities.knowledge_entities import IndexingEstimate from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError from core.indexing_runner import IndexingRunner from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager @@ -266,21 +267,10 @@ class ErrorDocsResponse(DocumentStatusListResponse): total: int -class IndexingEstimatePreviewItemResponse(ResponseModel): - content: str - child_chunks: list[str] | None = None - summary: str | None = None - - -class IndexingEstimateQaPreviewItemResponse(ResponseModel): - question: str - answer: str - - -class IndexingEstimateResponse(ResponseModel): - total_segments: int - preview: list[IndexingEstimatePreviewItemResponse] - qa_preview: list[IndexingEstimateQaPreviewItemResponse] | None = None +class IndexingEstimateResponse(IndexingEstimate): + tokens: int + total_price: float | int + currency: str class RetrievalSettingResponse(ResponseModel): @@ -640,7 +630,7 @@ def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): else: data["embedding_available"] = True - return data, 200 + return dump_response(DatasetDetailWithPartialMembersResponse, data), 200 @console_ns.doc("update_dataset") @console_ns.doc(description="Update dataset details") @@ -706,7 +696,7 @@ def patch(self, current_tenant_id: str, current_user: Account, dataset_id: UUID) partial_member_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str) result_data.update({"partial_member_list": partial_member_list}) - return result_data, 200 + return dump_response(DatasetDetailWithPartialMembersResponse, result_data), 200 @setup_required @login_required @@ -749,7 +739,7 @@ def get(self, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset_is_using = DatasetService.dataset_use_check(dataset_id_str) - return {"is_using": dataset_is_using}, 200 + return UsageCheckResponse(is_using=dataset_is_using).model_dump(mode="json"), 200 @console_ns.route("/datasets//queries") @@ -890,7 +880,17 @@ def post(self, current_tenant_id: str): except Exception as e: raise IndexingEstimateError(str(e)) - return response.model_dump(), 200 + return ( + IndexingEstimateResponse( + tokens=0, + total_price=0, + currency="USD", + total_segments=response.total_segments, + preview=response.preview, + qa_preview=response.qa_preview, + ).model_dump(mode="json", exclude_none=True), + 200, + ) @console_ns.route("/datasets//related-apps") @@ -1007,7 +1007,7 @@ def get(self, current_tenant_id: str): keys = db.session.scalars( select(ApiToken).where(ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id) ).all() - return ApiKeyList.model_validate({"data": keys}, from_attributes=True).model_dump(mode="json") + return dump_response(ApiKeyList, {"data": keys}) @console_ns.response(200, "API key created successfully", console_ns.models[ApiKeyItem.__name__]) @console_ns.response(400, "Maximum keys exceeded") @@ -1041,7 +1041,7 @@ def post(self, current_tenant_id: str): api_token.type = self.resource_type db.session.add(api_token) db.session.commit() - return ApiKeyItem.model_validate(api_token, from_attributes=True).model_dump(mode="json"), 200 + return dump_response(ApiKeyItem, api_token), 200 @console_ns.route("/datasets/api-keys/") @@ -1096,7 +1096,7 @@ def post(self, dataset_id: UUID, status: str): DatasetService.update_dataset_api_status(dataset_id_str, status == "enable") - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route("/datasets/api-base-info") @@ -1109,7 +1109,7 @@ class DatasetApiBaseUrlApi(Resource): @account_initialization_required def get(self): base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/") - return {"api_base_url": normalize_api_base_url(base)} + return ApiBaseUrlResponse(api_base_url=normalize_api_base_url(base)).model_dump(mode="json") @console_ns.route("/datasets/retrieval-setting") diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index 07e150617bffe5..ccaaba4cf726ee 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -10,16 +10,17 @@ import sqlalchemy as sa from flask import request, send_file from flask_restx import Resource -from pydantic import BaseModel, Field, RootModel, field_validator +from pydantic import BaseModel, Field, JsonValue, field_validator from sqlalchemy import asc, desc, func, select from werkzeug.exceptions import Forbidden, NotFound import services from controllers.common.controller_schemas import DocumentBatchDownloadZipPayload -from controllers.common.fields import BinaryFileResponse, SimpleResultMessageResponse, SimpleResultResponse, UrlResponse +from controllers.common.fields import SimpleResultMessageResponse, SimpleResultResponse, UrlResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import RBACPermission, RBACResourceScope, rbac_permission_required +from core.entities.knowledge_entities import IndexingEstimate from core.errors.error import ( LLMBadRequestError, ModelCurrentlyNotSupportError, @@ -29,6 +30,7 @@ from core.indexing_runner import IndexingRunner from core.model_manager import ModelManager from core.plugin.impl.exc import PluginDaemonClientSideError +from core.rag.entities import Rule from core.rag.extractor.entity.datasource_type import DatasourceType from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo, WebsiteInfo from core.rag.index_processor.constant.index_type import IndexTechniqueType @@ -48,7 +50,7 @@ from libs.login import login_required from models import Account, DatasetProcessRule, Document, DocumentSegment, UploadFile from models.dataset import DocumentPipelineExecutionLog -from models.enums import IndexingStatus, SegmentStatus +from models.enums import IndexingStatus, ProcessRuleMode, SegmentStatus from services.dataset_service import DatasetService, DocumentService from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig, ProcessRule, RetrievalModel from services.file_service import FileService @@ -146,8 +148,91 @@ class DocumentWithSegmentsListResponse(ResponseModel): page: int -class OpaqueObjectResponse(RootModel[dict[str, Any]]): - root: dict[str, Any] +class IndexingEstimateResponse(IndexingEstimate): + tokens: int + total_price: float | int + currency: str + + +class DocumentDetailResponse(ResponseModel): + id: str + position: int | None = None + data_source_type: str | None = None + data_source_info: Any = None + data_source_detail_dict: Any = None + dataset_process_rule_id: str | None = None + dataset_process_rule: Any = None + document_process_rule: Any = None + name: str | None = None + created_from: str | None = None + created_by: str | None = None + created_at: int | None = None + tokens: int | None = None + indexing_status: str | None = None + completed_at: int | None = None + updated_at: int | None = None + indexing_latency: float | None = None + error: str | None = None + enabled: bool | None = None + disabled_at: int | None = None + disabled_by: str | None = None + archived: bool | None = None + doc_type: str | None = None + doc_metadata: list[DocumentMetadataResponse] | None = None + segment_count: int | None = None + average_segment_length: float | None = None + hit_count: int | None = None + display_status: str | None = None + doc_form: str | None = None + doc_language: str | None = None + need_summary: bool | None = None + + @field_validator("data_source_type", "indexing_status", "display_status", "doc_form", mode="before") + @classmethod + def _normalize_enum_fields(cls, value: Any) -> Any: + return normalize_enum(value) + + +class SummaryStatusResponse(ResponseModel): + completed: int = 0 + generating: int = 0 + error: int = 0 + not_started: int = 0 + timeout: int = 0 + + +class SummaryEntryResponse(ResponseModel): + segment_id: str + segment_position: int + status: str + summary_preview: str | None = None + error: str | None = None + created_at: int | None = None + updated_at: int | None = None + + @field_validator("status", mode="before") + @classmethod + def _normalize_status(cls, value: Any) -> Any: + return normalize_enum(value) + + +class DocumentSummaryStatusResponse(ResponseModel): + total_segments: int + summary_status: SummaryStatusResponse + summaries: list[SummaryEntryResponse] + + +class ProcessRuleResponse(ResponseModel): + mode: ProcessRuleMode + rules: Rule | None = None + limits: dict[str, Any] + + +class DocumentPipelineExecutionLogResponse(ResponseModel): + datasource_info: JsonValue | None = None + datasource_type: str | None = None + input_data: JsonValue | None = None + datasource_node_id: str | None = None register_schema_models( @@ -163,7 +248,6 @@ class OpaqueObjectResponse(RootModel[dict[str, Any]]): ) register_response_schema_models( console_ns, - BinaryFileResponse, SimpleResultMessageResponse, SimpleResultResponse, UrlResponse, @@ -173,7 +257,11 @@ class OpaqueObjectResponse(RootModel[dict[str, Any]]): DocumentWithSegmentsResponse, DatasetAndDocumentResponse, DocumentWithSegmentsListResponse, - OpaqueObjectResponse, + IndexingEstimateResponse, + DocumentDetailResponse, + DocumentSummaryStatusResponse, + ProcessRuleResponse, + DocumentPipelineExecutionLogResponse, ) @@ -223,7 +311,7 @@ class GetProcessRuleApi(Resource): @console_ns.doc("get_process_rule") @console_ns.doc(description="Get dataset document processing rules") @console_ns.doc(params={"document_id": "Document ID (optional)"}) - @console_ns.response(200, "Process rules retrieved successfully", console_ns.models[OpaqueObjectResponse.__name__]) + @console_ns.response(200, "Process rules retrieved successfully", console_ns.models[ProcessRuleResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -262,7 +350,7 @@ def get(self, current_user: Account): mode = dataset_process_rule.mode rules = dataset_process_rule.rules_dict - return {"mode": mode, "rules": rules, "limits": limits} + return dump_response(ProcessRuleResponse, {"mode": mode, "rules": rules, "limits": limits}) @console_ns.route("/datasets//documents") @@ -485,7 +573,7 @@ class DatasetInitApi(Resource): @console_ns.doc(description="Initialize dataset with documents") @console_ns.expect(console_ns.models[KnowledgeConfig.__name__]) @console_ns.response( - 201, "Dataset initialized successfully", console_ns.models[DatasetAndDocumentResponse.__name__] + 200, "Dataset initialized successfully", console_ns.models[DatasetAndDocumentResponse.__name__] ) @console_ns.response(400, "Invalid request parameters") @setup_required @@ -550,7 +638,7 @@ class DocumentIndexingEstimateApi(DocumentResource): @console_ns.response( 200, "Indexing estimate calculated successfully", - console_ns.models[OpaqueObjectResponse.__name__], + console_ns.models[IndexingEstimateResponse.__name__], ) @console_ns.response(404, "Document not found") @console_ns.response(400, "Document already finished") @@ -571,8 +659,6 @@ def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, d data_process_rule = document.dataset_process_rule data_process_rule_dict = data_process_rule.to_dict() if data_process_rule else {} - response = {"tokens": 0, "total_price": 0, "currency": "USD", "total_segments": 0, "preview": []} - if document.data_source_type == "upload_file": data_source_info = document.data_source_info_dict if data_source_info and "upload_file_id" in data_source_info: @@ -603,7 +689,18 @@ def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, d "English", dataset_id_str, ) - return estimate_response.model_dump(), 200 + return ( + # TODO: why using zero here? the same for the below endpoint + IndexingEstimateResponse( + tokens=0, + total_price=0, + currency="USD", + total_segments=estimate_response.total_segments, + preview=estimate_response.preview, + qa_preview=estimate_response.qa_preview, + ).model_dump(mode="json", exclude_none=True), + 200, + ) except LLMBadRequestError: raise ProviderNotInitializeError( "No Embedding Model available. Please configure a valid provider " @@ -616,15 +713,24 @@ def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, d except Exception as e: raise IndexingEstimateError(str(e)) - return response, 200 + return ( + IndexingEstimateResponse( + tokens=0, + total_price=0, + currency="USD", + total_segments=0, + preview=[], + ).model_dump(mode="json", exclude_none=True), + 200, + ) @console_ns.route("/datasets//batch//indexing-estimate") class DocumentBatchIndexingEstimateApi(DocumentResource): @console_ns.response( 200, - "Batch indexing estimate calculated successfully", - console_ns.models[OpaqueObjectResponse.__name__], + "Indexing estimate calculated successfully", + console_ns.models[IndexingEstimateResponse.__name__], ) @setup_required @login_required @@ -636,7 +742,16 @@ def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, b dataset_id_str = str(dataset_id) documents = self.get_batch_documents(dataset_id_str, batch, current_user) if not documents: - return {"tokens": 0, "total_price": 0, "currency": "USD", "total_segments": 0, "preview": []}, 200 + return ( + IndexingEstimateResponse( + tokens=0, + total_price=0, + currency="USD", + total_segments=0, + preview=[], + ).model_dump(mode="json", exclude_none=True), + 200, + ) data_process_rule = documents[0].dataset_process_rule data_process_rule_dict = data_process_rule.to_dict() if data_process_rule else {} extract_settings = [] @@ -710,7 +825,17 @@ def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, b "English", dataset_id_str, ) - return response.model_dump(), 200 + return ( + IndexingEstimateResponse( + tokens=0, + total_price=0, + currency="USD", + total_segments=response.total_segments, + preview=response.preview, + qa_preview=response.qa_preview, + ).model_dump(mode="json", exclude_none=True), + 200, + ) except LLMBadRequestError: raise ProviderNotInitializeError( "No Embedding Model available. Please configure a valid provider in the Settings -> Model Provider." @@ -847,7 +972,7 @@ class DocumentApi(DocumentResource): "metadata": "Metadata inclusion (all/only/without)", } ) - @console_ns.response(200, "Document retrieved successfully", console_ns.models[OpaqueObjectResponse.__name__]) + @console_ns.response(200, "Document retrieved successfully", console_ns.models[DocumentDetailResponse.__name__]) @console_ns.response(404, "Document not found") @setup_required @login_required @@ -864,46 +989,21 @@ def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, d if metadata not in self.METADATA_CHOICES: raise InvalidMetadataError(f"Invalid metadata value: {metadata}") + metadata_fields = {"doc_type", "doc_metadata"} if metadata == "only": - response = {"id": document.id, "doc_type": document.doc_type, "doc_metadata": document.doc_metadata_details} - elif metadata == "without": - dataset_process_rules = DatasetService.get_process_rules(dataset_id_str) - document_process_rules = document.dataset_process_rule.to_dict() if document.dataset_process_rule else {} - response = { - "id": document.id, - "position": document.position, - "data_source_type": document.data_source_type, - "data_source_info": document.data_source_info_dict, - "data_source_detail_dict": document.data_source_detail_dict, - "dataset_process_rule_id": document.dataset_process_rule_id, - "dataset_process_rule": dataset_process_rules, - "document_process_rule": document_process_rules, - "name": document.name, - "created_from": document.created_from, - "created_by": document.created_by, - "created_at": int(document.created_at.timestamp()), - "tokens": document.tokens, - "indexing_status": document.indexing_status, - "completed_at": int(document.completed_at.timestamp()) if document.completed_at else None, - "updated_at": int(document.updated_at.timestamp()) if document.updated_at else None, - "indexing_latency": document.indexing_latency, - "error": document.error, - "enabled": document.enabled, - "disabled_at": int(document.disabled_at.timestamp()) if document.disabled_at else None, - "disabled_by": document.disabled_by, - "archived": document.archived, - "segment_count": document.segment_count, - "average_segment_length": document.average_segment_length, - "hit_count": document.hit_count, - "display_status": document.display_status, - "doc_form": document.doc_form, - "doc_language": document.doc_language, - "need_summary": document.need_summary if document.need_summary is not None else False, - } - else: - dataset_process_rules = DatasetService.get_process_rules(dataset_id_str) - document_process_rules = document.dataset_process_rule.to_dict() if document.dataset_process_rule else {} - response = { + response = DocumentDetailResponse.model_validate( + { + "id": document.id, + "doc_type": document.doc_type, + "doc_metadata": document.doc_metadata_details, + } + ) + return response.model_dump(mode="json", include={"id", *metadata_fields}, exclude_unset=True), 200 + + dataset_process_rules = DatasetService.get_process_rules(dataset_id_str) + document_process_rules = document.dataset_process_rule.to_dict() if document.dataset_process_rule else {} + response = DocumentDetailResponse.model_validate( + { "id": document.id, "position": document.position, "data_source_type": document.data_source_type, @@ -936,8 +1036,9 @@ def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, d "doc_language": document.doc_language, "need_summary": document.need_summary if document.need_summary is not None else False, } - - return response, 200 + ) + exclude = metadata_fields if metadata == "without" else None + return response.model_dump(mode="json", exclude=exclude, exclude_unset=True), 200 @setup_required @login_required @@ -983,7 +1084,7 @@ class DocumentDownloadApi(DocumentResource): def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID) -> dict[str, Any]: # Reuse the shared permission/tenant checks implemented in DocumentResource. document = self.get_document(str(dataset_id), str(document_id), current_user, current_tenant_id) - return {"url": DocumentService.get_document_download_url(document)} + return UrlResponse(url=DocumentService.get_document_download_url(document)).model_dump(mode="json") @console_ns.route("/datasets//documents/download-zip") @@ -992,7 +1093,7 @@ class DocumentBatchDownloadZipApi(DocumentResource): @console_ns.doc("download_dataset_documents_as_zip") @console_ns.doc(description="Download selected dataset documents as a single ZIP archive (upload-file only)") - @console_ns.response(200, "ZIP archive generated successfully", console_ns.models[BinaryFileResponse.__name__]) + @console_ns.response(200, "ZIP archive downloaded successfully") @setup_required @login_required @account_initialization_required @@ -1026,6 +1127,7 @@ def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): ) cleanup = stack.pop_all() response.call_on_close(cleanup.close) + # response-contract:ignore binary ZIP download response return response @@ -1085,7 +1187,7 @@ def patch( document.is_paused = False db.session.commit() - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route("/datasets//documents//metadata") @@ -1144,7 +1246,9 @@ def put(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, d document.updated_at = naive_utc_now() db.session.commit() - return {"result": "success", "message": "Document metadata updated."}, 200 + return SimpleResultMessageResponse(result="success", message="Document metadata updated.").model_dump( + mode="json" + ), 200 @console_ns.route("/datasets//documents/status//batch") @@ -1186,7 +1290,7 @@ def patch( except NotFound as e: raise NotFound(str(e)) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route("/datasets//documents//processing/pause") @@ -1355,15 +1459,15 @@ def get(self, current_tenant_id: str, dataset_id: UUID, document_id: UUID): # sync document DocumentService.sync_website_document(dataset_id_str, document) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route("/datasets//documents//pipeline-execution-log") class DocumentPipelineExecutionLogApi(DocumentResource): @console_ns.response( 200, - "Document pipeline execution log retrieved successfully", - console_ns.models[OpaqueObjectResponse.__name__], + "Pipeline execution log retrieved successfully", + console_ns.models[DocumentPipelineExecutionLogResponse.__name__], ) @setup_required @login_required @@ -1386,18 +1490,16 @@ def get(self, dataset_id: UUID, document_id: UUID): .limit(1) ) if not log: - return { - "datasource_info": None, - "datasource_type": None, - "input_data": None, - "datasource_node_id": None, - }, 200 - return { - "datasource_info": json.loads(log.datasource_info), - "datasource_type": log.datasource_type, - "input_data": log.input_data, - "datasource_node_id": log.datasource_node_id, - }, 200 + return DocumentPipelineExecutionLogResponse().model_dump(mode="json"), 200 + return dump_response( + DocumentPipelineExecutionLogResponse, + { + "datasource_info": json.loads(log.datasource_info), + "datasource_type": log.datasource_type, + "input_data": log.input_data, + "datasource_node_id": log.datasource_node_id, + }, + ), 200 @console_ns.route("/datasets//documents/generate-summary") @@ -1499,7 +1601,7 @@ def post(self, current_user: Account, dataset_id: UUID): dataset_id_str, ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route("/datasets//documents//summary-status") @@ -1507,7 +1609,11 @@ class DocumentSummaryStatusApi(DocumentResource): @console_ns.doc("get_document_summary_status") @console_ns.doc(description="Get summary index generation status for a document") @console_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"}) - @console_ns.response(200, "Summary status retrieved successfully", console_ns.models[OpaqueObjectResponse.__name__]) + @console_ns.response( + 200, + "Summary status retrieved successfully", + console_ns.models[DocumentSummaryStatusResponse.__name__], + ) @console_ns.response(404, "Document not found") @setup_required @login_required @@ -1525,6 +1631,7 @@ def get(self, current_user: Account, dataset_id: UUID, document_id: UUID): - generating: Number of summaries being generated - error: Number of summaries with errors - not_started: Number of segments without summary records + - timeout: Number of summaries that timed out - summaries: List of summary records with status and content preview """ dataset_id_str = str(dataset_id) @@ -1549,4 +1656,4 @@ def get(self, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id=dataset_id_str, ) - return result, 200 + return dump_response(DocumentSummaryStatusResponse, result), 200 diff --git a/api/controllers/console/datasets/datasets_segments.py b/api/controllers/console/datasets/datasets_segments.py index 4858b5ff6b07d2..cc94c3206a0046 100644 --- a/api/controllers/console/datasets/datasets_segments.py +++ b/api/controllers/console/datasets/datasets_segments.py @@ -374,7 +374,7 @@ def patch( SegmentService.update_segments_status(segment_ids, action, dataset, document) except Exception as e: raise InvalidActionError(str(e)) - return dump_response(SimpleResultResponse, {"result": "success"}), 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route("/datasets//documents//segment") diff --git a/api/controllers/console/datasets/external.py b/api/controllers/console/datasets/external.py index eb7b9aa84f8022..ba9c66302f75c2 100644 --- a/api/controllers/console/datasets/external.py +++ b/api/controllers/console/datasets/external.py @@ -1,19 +1,15 @@ +from datetime import datetime from typing import Any from uuid import UUID from flask import request -from flask_restx import Resource, fields, marshal -from pydantic import BaseModel, Field, RootModel +from flask_restx import Resource +from pydantic import AliasChoices, BaseModel, Field, field_validator from werkzeug.exceptions import Forbidden, InternalServerError, NotFound import services from controllers.common.fields import UsageCountResponse -from controllers.common.schema import ( - get_or_create_model, - query_params_from_model, - register_response_schema_models, - register_schema_models, -) +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.datasets.error import DatasetNameDuplicateError from controllers.console.wraps import ( @@ -28,19 +24,8 @@ ) from extensions.ext_database import db from fields.base import ResponseModel -from fields.dataset_fields import ( - dataset_detail_fields, - dataset_retrieval_model_fields, - doc_metadata_fields, - external_knowledge_info_fields, - external_retrieval_model_fields, - icon_info_fields, - keyword_setting_fields, - reranking_model_fields, - tag_fields, - vector_setting_fields, - weighted_score_fields, -) +from fields.dataset_fields import DatasetDetailResponse +from libs.helper import dump_response from libs.login import login_required from models import Account from services.dataset_service import DatasetService @@ -49,50 +34,10 @@ from services.hit_testing_service import HitTestingService from services.knowledge_service import BedrockRetrievalSetting, ExternalDatasetTestService -register_response_schema_models(console_ns, UsageCountResponse) - - -def _build_dataset_detail_model(): - keyword_setting_model = get_or_create_model("DatasetKeywordSetting", keyword_setting_fields) - vector_setting_model = get_or_create_model("DatasetVectorSetting", vector_setting_fields) - - weighted_score_fields_copy = weighted_score_fields.copy() - weighted_score_fields_copy["keyword_setting"] = fields.Nested(keyword_setting_model) - weighted_score_fields_copy["vector_setting"] = fields.Nested(vector_setting_model) - weighted_score_model = get_or_create_model("DatasetWeightedScore", weighted_score_fields_copy) - - reranking_model = get_or_create_model("DatasetRerankingModel", reranking_model_fields) - - dataset_retrieval_model_fields_copy = dataset_retrieval_model_fields.copy() - dataset_retrieval_model_fields_copy["reranking_model"] = fields.Nested(reranking_model) - dataset_retrieval_model_fields_copy["weights"] = fields.Nested(weighted_score_model, allow_null=True) - dataset_retrieval_model = get_or_create_model("DatasetRetrievalModel", dataset_retrieval_model_fields_copy) - - tag_model = get_or_create_model("Tag", tag_fields) - doc_metadata_model = get_or_create_model("DatasetDocMetadata", doc_metadata_fields) - external_knowledge_info_model = get_or_create_model("ExternalKnowledgeInfo", external_knowledge_info_fields) - external_retrieval_model = get_or_create_model("ExternalRetrievalModel", external_retrieval_model_fields) - icon_info_model = get_or_create_model("DatasetIconInfo", icon_info_fields) - - dataset_detail_fields_copy = dataset_detail_fields.copy() - dataset_detail_fields_copy["retrieval_model_dict"] = fields.Nested(dataset_retrieval_model) - dataset_detail_fields_copy["tags"] = fields.List(fields.Nested(tag_model)) - dataset_detail_fields_copy["external_knowledge_info"] = fields.Nested(external_knowledge_info_model) - dataset_detail_fields_copy["external_retrieval_model"] = fields.Nested(external_retrieval_model, allow_null=True) - dataset_detail_fields_copy["doc_metadata"] = fields.List(fields.Nested(doc_metadata_model)) - dataset_detail_fields_copy["icon_info"] = fields.Nested(icon_info_model) - return get_or_create_model("DatasetDetail", dataset_detail_fields_copy) - - -try: - dataset_detail_model = console_ns.models["DatasetDetail"] -except KeyError: - dataset_detail_model = _build_dataset_detail_model() - class ExternalKnowledgeApiPayload(BaseModel): name: str = Field(..., min_length=1, max_length=40) - settings: dict[str, object] + settings: dict[str, Any] class ExternalDatasetCreatePayload(BaseModel): @@ -100,15 +45,13 @@ class ExternalDatasetCreatePayload(BaseModel): external_knowledge_id: str name: str = Field(..., min_length=1, max_length=100) description: str | None = Field(None, max_length=400) - external_retrieval_model: dict[str, object] | None = Field(default=None) + external_retrieval_model: dict[str, Any] | None = None class ExternalHitTestingPayload(BaseModel): query: str - external_retrieval_model: dict[str, object] | None = Field(default=None) - metadata_filtering_conditions: dict[str, object] | None = Field( - default=None, - ) + external_retrieval_model: dict[str, Any] | None = None + metadata_filtering_conditions: dict[str, Any] | None = None class BedrockRetrievalPayload(BaseModel): @@ -123,7 +66,7 @@ class ExternalApiTemplateListQuery(BaseModel): keyword: str | None = Field(default=None, description="Search keyword") -class ExternalKnowledgeDatasetBindingResponse(ResponseModel): +class ExternalKnowledgeApiBindingResponse(ResponseModel): id: str name: str @@ -133,22 +76,52 @@ class ExternalKnowledgeApiResponse(ResponseModel): tenant_id: str name: str description: str - settings: dict[str, Any] | None = Field(default=None) - dataset_bindings: list[ExternalKnowledgeDatasetBindingResponse] = Field(default_factory=list) + settings: dict[str, Any] | None = Field(validation_alias=AliasChoices("settings_dict", "settings")) + dataset_bindings: list[ExternalKnowledgeApiBindingResponse] created_by: str created_at: str + @field_validator("created_at", mode="before") + @classmethod + def _normalize_created_at(cls, value: datetime | str) -> str: + if isinstance(value, datetime): + return value.isoformat() + return value + class ExternalKnowledgeApiListResponse(ResponseModel): data: list[ExternalKnowledgeApiResponse] has_more: bool limit: int - total: int + total: int | None page: int -class ExternalRetrievalTestResponse(RootModel[dict[str, Any] | list[dict[str, Any]]]): - root: dict[str, Any] | list[dict[str, Any]] +class ExternalHitTestingQueryResponse(ResponseModel): + content: str + + +class ExternalHitTestingRecordResponse(ResponseModel): + content: str | None = None + title: str | None = None + score: float | None = None + metadata: dict[str, Any] | None = None + + +class ExternalHitTestingResponse(ResponseModel): + query: ExternalHitTestingQueryResponse + records: list[ExternalHitTestingRecordResponse] + + +class BedrockRetrievalRecordResponse(ResponseModel): + metadata: dict[str, Any] | None = None + score: float + title: str | None = None + content: str | None = None + + +class BedrockRetrievalResponse(ResponseModel): + records: list[BedrockRetrievalRecordResponse] register_schema_models( @@ -161,9 +134,16 @@ class ExternalRetrievalTestResponse(RootModel[dict[str, Any] | list[dict[str, An ) register_response_schema_models( console_ns, + UsageCountResponse, + DatasetDetailResponse, + ExternalKnowledgeApiBindingResponse, ExternalKnowledgeApiResponse, ExternalKnowledgeApiListResponse, - ExternalRetrievalTestResponse, + ExternalHitTestingQueryResponse, + ExternalHitTestingRecordResponse, + ExternalHitTestingResponse, + BedrockRetrievalRecordResponse, + BedrockRetrievalResponse, ) @@ -187,24 +167,26 @@ def get(self, current_tenant_id: str): external_knowledge_apis, total = ExternalDatasetService.get_external_knowledge_apis( query.page, query.limit, current_tenant_id, query.keyword ) - response = { - "data": [item.to_dict() for item in external_knowledge_apis], - "has_more": len(external_knowledge_apis) == query.limit, - "limit": query.limit, - "total": total, - "page": query.page, - } - return response, 200 - - @setup_required - @login_required - @account_initialization_required + return ExternalKnowledgeApiListResponse( + data=[ExternalKnowledgeApiResponse.model_validate(item) for item in external_knowledge_apis], + has_more=len(external_knowledge_apis) == query.limit, + limit=query.limit, + total=total, + page=query.page, + ).model_dump(mode="json"), 200 + + @console_ns.doc("create_external_api_template") + @console_ns.doc(description="Create external knowledge API template") @console_ns.expect(console_ns.models[ExternalKnowledgeApiPayload.__name__]) @console_ns.response( 201, "External API template created successfully", console_ns.models[ExternalKnowledgeApiResponse.__name__], ) + @console_ns.response(403, "Permission denied") + @setup_required + @login_required + @account_initialization_required @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account): @@ -223,7 +205,7 @@ def post(self, current_tenant_id: str, current_user: Account): except services.errors.dataset.DatasetNameDuplicateError: raise DatasetNameDuplicateError() - return external_knowledge_api.to_dict(), 201 + return dump_response(ExternalKnowledgeApiResponse, external_knowledge_api), 201 @console_ns.route("/datasets/external-knowledge-api/") @@ -249,17 +231,21 @@ def get(self, current_tenant_id: str, external_knowledge_api_id: UUID): if external_knowledge_api is None: raise NotFound("API template not found.") - return external_knowledge_api.to_dict(), 200 + return dump_response(ExternalKnowledgeApiResponse, external_knowledge_api), 200 + @console_ns.doc("update_external_api_template") + @console_ns.doc(description="Update external knowledge API template") + @console_ns.doc(params={"external_knowledge_api_id": "External knowledge API ID"}) + @console_ns.expect(console_ns.models[ExternalKnowledgeApiPayload.__name__]) @console_ns.response( 200, "External API template updated successfully", console_ns.models[ExternalKnowledgeApiResponse.__name__], ) + @console_ns.response(404, "Template not found") @setup_required @login_required @account_initialization_required - @console_ns.expect(console_ns.models[ExternalKnowledgeApiPayload.__name__]) @with_current_user @with_current_tenant_id def patch(self, current_tenant_id: str, current_user: Account, external_knowledge_api_id: UUID): @@ -275,7 +261,7 @@ def patch(self, current_tenant_id: str, current_user: Account, external_knowledg args=payload.model_dump(), ) - return external_knowledge_api.to_dict(), 200 + return dump_response(ExternalKnowledgeApiResponse, external_knowledge_api), 200 @setup_required @login_required @@ -309,7 +295,7 @@ def get(self, current_tenant_id: str, external_knowledge_api_id: UUID): external_knowledge_api_is_using, count = ExternalDatasetService.external_knowledge_api_use_check( external_knowledge_api_id_str, current_tenant_id ) - return {"is_using": external_knowledge_api_is_using, "count": count}, 200 + return UsageCountResponse(is_using=external_knowledge_api_is_using, count=count).model_dump(mode="json"), 200 @console_ns.route("/datasets/external") @@ -317,7 +303,9 @@ class ExternalDatasetCreateApi(Resource): @console_ns.doc("create_external_dataset") @console_ns.doc(description="Create external knowledge dataset") @console_ns.expect(console_ns.models[ExternalDatasetCreatePayload.__name__]) - @console_ns.response(201, "External dataset created successfully", dataset_detail_model) + @console_ns.response( + 201, "External dataset created successfully", console_ns.models[DatasetDetailResponse.__name__] + ) @console_ns.response(400, "Invalid parameters") @console_ns.response(403, "Permission denied") @setup_required @@ -345,16 +333,15 @@ def post(self, current_tenant_id: str, current_user: Account): except services.errors.dataset.DatasetNameDuplicateError: raise DatasetNameDuplicateError() - item = marshal(dataset, dataset_detail_fields) - dataset_id_str = item["id"] + dataset_id_str = str(dataset.id) permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get( str(current_tenant_id), current_user.id, [dataset_id_str], ) - item["permission_keys"] = permission_keys_map.get(dataset_id_str, []) - - return item, 201 + data = DatasetDetailResponse.model_validate(dataset).model_dump(mode="json") + data["permission_keys"] = permission_keys_map.get(dataset_id_str, []) + return data, 201 @console_ns.route("/datasets//external-hit-testing") @@ -366,7 +353,7 @@ class ExternalKnowledgeHitTestingApi(Resource): @console_ns.response( 200, "External hit testing completed successfully", - console_ns.models[ExternalRetrievalTestResponse.__name__], + console_ns.models[ExternalHitTestingResponse.__name__], ) @console_ns.response(404, "Dataset not found") @console_ns.response(400, "Invalid parameters") @@ -399,7 +386,7 @@ def post(self, current_user: Account, dataset_id: UUID): metadata_filtering_conditions=payload.metadata_filtering_conditions, ) - return response + return dump_response(ExternalHitTestingResponse, response) except Exception as e: raise InternalServerError(str(e)) @@ -410,11 +397,7 @@ class BedrockRetrievalApi(Resource): @console_ns.doc("bedrock_retrieval_test") @console_ns.doc(description="Bedrock retrieval test (internal use only)") @console_ns.expect(console_ns.models[BedrockRetrievalPayload.__name__]) - @console_ns.response( - 200, - "Bedrock retrieval test completed", - console_ns.models[ExternalRetrievalTestResponse.__name__], - ) + @console_ns.response(200, "Bedrock retrieval test completed", console_ns.models[BedrockRetrievalResponse.__name__]) def post(self): payload = BedrockRetrievalPayload.model_validate(console_ns.payload or {}) @@ -422,4 +405,4 @@ def post(self): result = ExternalDatasetTestService.knowledge_retrieval( payload.retrieval_setting, payload.query, payload.knowledge_id ) - return result, 200 + return dump_response(BedrockRetrievalResponse, result), 200 diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py index a575760ee19ba4..389515bd4f9690 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py @@ -6,7 +6,7 @@ from werkzeug.exceptions import Forbidden, NotFound from configs import dify_config -from controllers.common.fields import RedirectResponse, SimpleResultResponse +from controllers.common.fields import SimpleResultResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( @@ -19,11 +19,13 @@ with_current_tenant_id, with_current_user, ) +from core.entities.provider_entities import ProviderConfig from core.plugin.entities.plugin_daemon import PluginOAuthAuthorizationUrlResponse from core.plugin.impl.oauth import OAuthHandler +from core.tools.entities.common_entities import I18nObject from fields.base import ResponseModel from graphon.model_runtime.errors.validate import CredentialsValidateFailedError -from graphon.model_runtime.utils.encoders import jsonable_encoder +from libs.helper import dump_response from libs.login import login_required from models import Account from models.provider_ids import DatasourceProviderID @@ -33,7 +35,9 @@ class DatasourceCredentialPayload(BaseModel): name: str | None = Field(default=None, max_length=100) - credentials: dict[str, Any] + credentials: dict[str, Any] = Field( + description="Plugin-defined credential parameters. The schema is declared by the datasource provider." + ) class DatasourceCredentialDeletePayload(BaseModel): @@ -43,11 +47,17 @@ class DatasourceCredentialDeletePayload(BaseModel): class DatasourceCredentialUpdatePayload(BaseModel): credential_id: str name: str | None = Field(default=None, max_length=100) - credentials: dict[str, Any] | None = Field(default=None) + credentials: dict[str, Any] | None = Field( + default=None, + description="Plugin-defined credential parameters. The schema is declared by the datasource provider.", + ) class DatasourceCustomClientPayload(BaseModel): - client_params: dict[str, Any] | None = Field(default=None) + client_params: dict[str, Any] | None = Field( + default=None, + description="Plugin-defined OAuth client parameters. The schema is declared by the datasource provider.", + ) enable_oauth_custom_client: bool | None = None @@ -71,8 +81,48 @@ class DatasourceOAuthCallbackQuery(BaseModel): context_id: str | None = Field(default=None, description="OAuth proxy context ID") -class DatasourceCredentialsResponse(ResponseModel): - result: Any +class DatasourceCredentialResponse(ResponseModel): + credential: dict[str, Any] = Field( + description="Obfuscated plugin-defined credential parameters from the datasource provider." + ) + type: str + name: str + avatar_url: str | None + id: str + is_default: bool + + +class DatasourceCredentialListResponse(ResponseModel): + result: list[DatasourceCredentialResponse] + + +class DatasourceOAuthSchemaResponse(ResponseModel): + client_schema: list[ProviderConfig] + credentials_schema: list[ProviderConfig] + oauth_custom_client_params: dict[str, Any] | None = Field( + description="Masked plugin-defined OAuth client parameters, when configured for the tenant." + ) + is_oauth_custom_client_enabled: bool + is_system_oauth_params_exists: bool + redirect_uri: str + + +class DatasourceProviderAuthResponse(ResponseModel): + author: str + provider: str + plugin_id: str + plugin_unique_identifier: str + icon: str + name: str + label: I18nObject + description: I18nObject + credential_schema: list[ProviderConfig] + oauth_schema: DatasourceOAuthSchemaResponse | None + credentials_list: list[DatasourceCredentialResponse] + + +class DatasourceProviderAuthListResponse(ResponseModel): + result: list[DatasourceProviderAuthResponse] register_schema_models( @@ -88,9 +138,9 @@ class DatasourceCredentialsResponse(ResponseModel): ) register_response_schema_models( console_ns, - DatasourceCredentialsResponse, + DatasourceCredentialListResponse, + DatasourceProviderAuthListResponse, PluginOAuthAuthorizationUrlResponse, - RedirectResponse, SimpleResultResponse, ) @@ -100,7 +150,7 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource): @console_ns.doc(params=query_params_from_model(DatasourceOAuthAuthorizationQuery)) @console_ns.response( 200, - "Authorization URL retrieved successfully", + "Datasource OAuth authorization URL generated successfully", console_ns.models[PluginOAuthAuthorizationUrlResponse.__name__], ) @setup_required @@ -140,7 +190,8 @@ def get(self, current_tenant_id: str, current_user: Account, provider_id: str): redirect_uri=redirect_uri, system_credentials=oauth_config, ) - response = make_response(jsonable_encoder(authorization_url_response)) + # response-contract:ignore cookie-bearing Flask response + response = make_response(dump_response(PluginOAuthAuthorizationUrlResponse, authorization_url_response)) response.set_cookie( "context_id", context_id, @@ -154,11 +205,8 @@ def get(self, current_tenant_id: str, current_user: Account, provider_id: str): @console_ns.route("/oauth/plugin//datasource/callback") class DatasourceOAuthCallback(Resource): @console_ns.doc(params=query_params_from_model(DatasourceOAuthCallbackQuery)) - @console_ns.response( - 302, - "Redirect to console OAuth callback page", - console_ns.models[RedirectResponse.__name__], - ) + # response-contract:ignore redirect response + @console_ns.response(302, "Redirect to OAuth callback page") @setup_required def get(self, provider_id: str): context_id = request.cookies.get("context_id") or request.args.get("context_id") @@ -217,7 +265,9 @@ def get(self, provider_id: str): @console_ns.route("/auth/plugin/datasource/") class DatasourceAuth(Resource): @console_ns.expect(console_ns.models[DatasourceCredentialPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response( + 200, "Datasource credential created successfully", console_ns.models[SimpleResultResponse.__name__] + ) @setup_required @login_required @account_initialization_required @@ -238,12 +288,16 @@ def post(self, current_tenant_id: str, provider_id: str): ) except CredentialsValidateFailedError as ex: raise ValueError(str(ex)) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 + @console_ns.response( + 200, + "Datasource credentials retrieved successfully", + console_ns.models[DatasourceCredentialListResponse.__name__], + ) @setup_required @login_required @account_initialization_required - @console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__]) @with_current_user @with_current_tenant_id def get(self, current_tenant_id: str, user: Account, provider_id: str): @@ -256,7 +310,7 @@ def get(self, current_tenant_id: str, user: Account, provider_id: str): plugin_id=datasource_provider_id.plugin_id, user=user, ) - return {"result": datasources}, 200 + return dump_response(DatasourceCredentialListResponse, {"result": datasources}), 200 @console_ns.route("/auth/plugin/datasource//delete") @@ -282,13 +336,15 @@ def post(self, current_tenant_id: str, provider_id: str): provider=provider_name, plugin_id=plugin_id, ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route("/auth/plugin/datasource//update") class DatasourceAuthUpdateApi(Resource): @console_ns.expect(console_ns.models[DatasourceCredentialUpdatePayload.__name__]) - @console_ns.response(201, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response( + 201, "Datasource credential updated successfully", console_ns.models[SimpleResultResponse.__name__] + ) @setup_required @login_required @account_initialization_required @@ -308,12 +364,16 @@ def post(self, current_tenant_id: str, provider_id: str): credentials=payload.credentials or {}, name=payload.name, ) - return {"result": "success"}, 201 + return SimpleResultResponse(result="success").model_dump(mode="json"), 201 @console_ns.route("/auth/plugin/datasource/list") class DatasourceAuthListApi(Resource): - @console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__]) + @console_ns.response( + 200, + "Datasource credentials retrieved successfully", + console_ns.models[DatasourceProviderAuthListResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -321,12 +381,16 @@ class DatasourceAuthListApi(Resource): def get(self, current_tenant_id: str): datasource_provider_service = DatasourceProviderService() datasources = datasource_provider_service.get_all_datasource_credentials(tenant_id=current_tenant_id) - return {"result": jsonable_encoder(datasources)}, 200 + return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200 @console_ns.route("/auth/plugin/datasource/default-list") class DatasourceHardCodeAuthListApi(Resource): - @console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__]) + @console_ns.response( + 200, + "Default datasource credentials retrieved successfully", + console_ns.models[DatasourceProviderAuthListResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -334,13 +398,15 @@ class DatasourceHardCodeAuthListApi(Resource): def get(self, current_tenant_id: str): datasource_provider_service = DatasourceProviderService() datasources = datasource_provider_service.get_hard_code_datasource_credentials(tenant_id=current_tenant_id) - return {"result": jsonable_encoder(datasources)}, 200 + return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200 @console_ns.route("/auth/plugin/datasource//custom-client") class DatasourceAuthOauthCustomClient(Resource): @console_ns.expect(console_ns.models[DatasourceCustomClientPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response( + 200, "Datasource OAuth custom client saved successfully", console_ns.models[SimpleResultResponse.__name__] + ) @setup_required @login_required @account_initialization_required @@ -357,7 +423,7 @@ def post(self, current_tenant_id: str, provider_id: str): client_params=payload.client_params or {}, enabled=payload.enable_oauth_custom_client or False, ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @setup_required @login_required @@ -371,7 +437,7 @@ def delete(self, current_tenant_id: str, provider_id: str): tenant_id=current_tenant_id, datasource_provider_id=datasource_provider_id, ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route("/auth/plugin/datasource//default") @@ -393,7 +459,7 @@ def post(self, current_tenant_id: str, provider_id: str): datasource_provider_id=datasource_provider_id, credential_id=payload.id, ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route("/auth/plugin/datasource//update-name") @@ -416,4 +482,4 @@ def post(self, current_tenant_id: str, provider_id: str): name=payload.name, credential_id=payload.credential_id, ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py b/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py index b0af108444c69e..213337fedc92c2 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py @@ -3,9 +3,9 @@ from flask_restx import ( # type: ignore Resource, # type: ignore ) -from pydantic import BaseModel, RootModel +from pydantic import BaseModel -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.datasets.wraps import get_rag_pipeline from controllers.console.wraps import account_initialization_required, setup_required, with_current_user @@ -21,18 +21,13 @@ class Parser(BaseModel): credential_id: str | None = None -class DataSourceContentPreviewResponse(RootModel[Any]): - root: Any - - register_schema_models(console_ns, Parser) -register_response_schema_models(console_ns, DataSourceContentPreviewResponse) @console_ns.route("/rag/pipelines//workflows/published/datasource/nodes//preview") class DataSourceContentPreviewApi(Resource): @console_ns.expect(console_ns.models[Parser.__name__]) - @console_ns.response(200, "Success", console_ns.models[DataSourceContentPreviewResponse.__name__]) + @console_ns.response(200, "Success") @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py index af417f24dfe6ce..900154824030d3 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py @@ -1,27 +1,27 @@ import logging from collections.abc import Callable from functools import wraps -from typing import Any, Concatenate, NoReturn +from typing import Any, Concatenate from uuid import UUID from flask import Response, request -from flask_restx import Resource, marshal, marshal_with +from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden from controllers.common.errors import InvalidArgumentError, NotFoundError -from controllers.common.schema import query_params_from_model, register_schema_models +from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( DraftWorkflowNotExist, ) from controllers.console.app.workflow_draft_variable import ( - _WORKFLOW_DRAFT_VARIABLE_FIELDS, # type: ignore[private-usage] - EnvironmentVariableListResponse, - workflow_draft_variable_list_model, - workflow_draft_variable_list_without_value_model, - workflow_draft_variable_model, + WorkflowDraftVariableListResponse, + WorkflowDraftVariableListWithoutValueResponse, + WorkflowDraftVariableResponse, + WorkflowDraftVariableUpdatePayload, + validate_node_id, ) from controllers.console.datasets.wraps import get_rag_pipeline from controllers.console.wraps import account_initialization_required, setup_required, with_current_user @@ -30,6 +30,7 @@ from extensions.ext_database import db from factories.file_factory import build_from_mapping, build_from_mappings from factories.variable_factory import build_segment_with_type +from fields.base import ResponseModel from graphon.variables.types import SegmentType from libs.login import login_required from models import Account @@ -46,12 +47,36 @@ class PaginationQuery(BaseModel): limit: int = Field(default=20, ge=1, le=100) -class WorkflowDraftVariablePatchPayload(BaseModel): - name: str | None = None - value: Any | None = None +class WorkflowDraftVariablePatchPayload(WorkflowDraftVariableUpdatePayload): + value: Any = None + + +class RagPipelineEnvironmentVariableResponse(ResponseModel): + id: str + type: str + name: str + description: str + selector: list[str] + value_type: str + value: Any + edited: bool + visible: bool + editable: bool + + +class RagPipelineEnvironmentVariableListResponse(ResponseModel): + items: list[RagPipelineEnvironmentVariableResponse] register_schema_models(console_ns, PaginationQuery, WorkflowDraftVariablePatchPayload) +register_response_schema_models( + console_ns, + WorkflowDraftVariableResponse, + WorkflowDraftVariableListResponse, + WorkflowDraftVariableListWithoutValueResponse, + RagPipelineEnvironmentVariableResponse, + RagPipelineEnvironmentVariableListResponse, +) def _api_prerequisite[T, **P, R]( @@ -83,14 +108,12 @@ def wrapper(self: T, current_user: Account, *args: P.args, **kwargs: P.kwargs) - @console_ns.route("/rag/pipelines//workflows/draft/variables") class RagPipelineVariableCollectionApi(Resource): - @console_ns.doc(params=query_params_from_model(PaginationQuery)) @console_ns.response( 200, - "Workflow variables retrieved successfully", - workflow_draft_variable_list_without_value_model, + "Variables retrieved successfully", + console_ns.models[WorkflowDraftVariableListWithoutValueResponse.__name__], ) @_api_prerequisite - @marshal_with(workflow_draft_variable_list_without_value_model) def get(self, current_user: Account, pipeline: Pipeline): """ Get draft workflow @@ -115,9 +138,11 @@ def get(self, current_user: Account, pipeline: Pipeline): user_id=current_user.id, ) - return workflow_vars + return WorkflowDraftVariableListWithoutValueResponse.from_workflow_draft_variable_list( + workflow_vars + ).model_dump(mode="json") - @console_ns.response(204, "Workflow variables deleted successfully") + @console_ns.response(204, "Variables deleted successfully") @_api_prerequisite def delete(self, current_user: Account, pipeline: Pipeline): draft_var_srv = WorkflowDraftVariableService( @@ -125,32 +150,17 @@ def delete(self, current_user: Account, pipeline: Pipeline): ) draft_var_srv.delete_user_workflow_variables(pipeline.id, user_id=current_user.id) db.session.commit() - return Response("", 204) - - -def validate_node_id(node_id: str) -> NoReturn | None: - if node_id in [ - CONVERSATION_VARIABLE_NODE_ID, - SYSTEM_VARIABLE_NODE_ID, - ]: - # NOTE(QuantumGhost): While we store the system and conversation variables as node variables - # with specific `node_id` in database, we still want to make the API separated. By disallowing - # accessing system and conversation variables in `WorkflowDraftNodeVariableListApi`, - # we mitigate the risk that user of the API depending on the implementation detail of the API. - # - # ref: [Hyrum's Law](https://www.hyrumslaw.com/) - - raise InvalidArgumentError( - f"invalid node_id, please use correspond api for conversation and system variables, node_id={node_id}", - ) - return None + return "", 204 @console_ns.route("/rag/pipelines//workflows/draft/nodes//variables") class RagPipelineNodeVariableCollectionApi(Resource): - @console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model) + @console_ns.response( + 200, + "Node variables retrieved successfully", + console_ns.models[WorkflowDraftVariableListResponse.__name__], + ) @_api_prerequisite - @marshal_with(workflow_draft_variable_list_model) def get(self, current_user: Account, pipeline: Pipeline, node_id: str): validate_node_id(node_id) with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: @@ -159,7 +169,7 @@ def get(self, current_user: Account, pipeline: Pipeline, node_id: str): ) node_vars = draft_var_srv.list_node_variables(pipeline.id, node_id, user_id=current_user.id) - return node_vars + return WorkflowDraftVariableListResponse.from_workflow_draft_variable_list(node_vars).model_dump(mode="json") @console_ns.response(204, "Node variables deleted successfully") @_api_prerequisite @@ -168,17 +178,17 @@ def delete(self, current_user: Account, pipeline: Pipeline, node_id: str): srv = WorkflowDraftVariableService(db.session()) srv.delete_node_variables(pipeline.id, node_id, user_id=current_user.id) db.session.commit() - return Response("", 204) + return "", 204 @console_ns.route("/rag/pipelines//workflows/draft/variables/") class RagPipelineVariableApi(Resource): - _PATCH_NAME_FIELD = "name" - _PATCH_VALUE_FIELD = "value" - - @console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model) + @console_ns.response( + 200, + "Variable retrieved successfully", + console_ns.models[WorkflowDraftVariableResponse.__name__], + ) @_api_prerequisite - @marshal_with(workflow_draft_variable_model) def get(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): draft_var_srv = WorkflowDraftVariableService( session=db.session(), @@ -189,11 +199,14 @@ def get(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): raise NotFoundError(description=f"variable not found, id={variable_id_str}") if variable.app_id != pipeline.id: raise NotFoundError(description=f"variable not found, id={variable_id_str}") - return variable + return WorkflowDraftVariableResponse.from_workflow_draft_variable(variable).model_dump(mode="json") - @console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model) + @console_ns.response( + 200, + "Variable updated successfully", + console_ns.models[WorkflowDraftVariableResponse.__name__], + ) @_api_prerequisite - @marshal_with(workflow_draft_variable_model) @console_ns.expect(console_ns.models[WorkflowDraftVariablePatchPayload.__name__]) def patch(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): # Request payload for file types: @@ -221,7 +234,6 @@ def patch(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): session=db.session(), ) payload = WorkflowDraftVariablePatchPayload.model_validate(console_ns.payload or {}) - args = payload.model_dump(exclude_none=True) variable_id_str = str(variable_id) variable = draft_var_srv.get_variable(variable_id=variable_id_str) @@ -230,18 +242,22 @@ def patch(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): if variable.app_id != pipeline.id: raise NotFoundError(description=f"variable not found, id={variable_id_str}") - new_name = args.get(self._PATCH_NAME_FIELD, None) - raw_value = args.get(self._PATCH_VALUE_FIELD, None) + new_name = payload.name + raw_value = payload.value if new_name is None and raw_value is None: - return variable + return WorkflowDraftVariableResponse.from_workflow_draft_variable(variable).model_dump(mode="json") + # TODO: duplication w/ controllers/console/app/workflow_draft_variable.py(L462) + # extract if proper (need to find a better location to prevent accident + # behavioral change) new_value = None if raw_value is not None: + new_value_input: Any match variable.value_type: case SegmentType.FILE: if not isinstance(raw_value, dict): raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") - raw_value = build_from_mapping( + new_value_input = build_from_mapping( mapping=raw_value, tenant_id=pipeline.tenant_id, access_controller=_file_access_controller, @@ -249,19 +265,22 @@ def patch(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): case SegmentType.ARRAY_FILE: if not isinstance(raw_value, list): raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") - if len(raw_value) > 0 and not isinstance(raw_value[0], dict): - raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") - raw_value = build_from_mappings( + for index, item in enumerate(raw_value): + if not isinstance(item, dict): + raise InvalidArgumentError( + description=f"expected dict for files[{index}], got {type(item)}" + ) + new_value_input = build_from_mappings( mappings=raw_value, tenant_id=pipeline.tenant_id, access_controller=_file_access_controller, ) case _: - pass - new_value = build_segment_with_type(variable.value_type, raw_value) + new_value_input = raw_value + new_value = build_segment_with_type(variable.value_type, new_value_input) draft_var_srv.update_variable(variable, name=new_name, value=new_value) db.session.commit() - return variable + return WorkflowDraftVariableResponse.from_workflow_draft_variable(variable).model_dump(mode="json") @console_ns.response(204, "Variable deleted successfully") @_api_prerequisite @@ -277,13 +296,17 @@ def delete(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): raise NotFoundError(description=f"variable not found, id={variable_id_str}") draft_var_srv.delete_variable(variable) db.session.commit() - return Response("", 204) + return "", 204 @console_ns.route("/rag/pipelines//workflows/draft/variables//reset") class RagPipelineVariableResetApi(Resource): - @console_ns.response(200, "Variable reset successfully", workflow_draft_variable_model) - @console_ns.response(204, "Variable reset (no content)") + @console_ns.response( + 200, + "Variable reset successfully", + console_ns.models[WorkflowDraftVariableResponse.__name__], + ) + @console_ns.response(204, "Variable reset to empty state") @_api_prerequisite def put(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): draft_var_srv = WorkflowDraftVariableService( @@ -306,9 +329,8 @@ def put(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): resetted = draft_var_srv.reset_variable(draft_workflow, variable) db.session.commit() if resetted is None: - return Response("", 204) - else: - return marshal(resetted, _WORKFLOW_DRAFT_VARIABLE_FIELDS) + return "", 204 + return WorkflowDraftVariableResponse.from_workflow_draft_variable(resetted).model_dump(mode="json") def _get_variable_list(pipeline: Pipeline, node_id: str, current_user_id: str) -> WorkflowDraftVariableList: @@ -327,11 +349,16 @@ def _get_variable_list(pipeline: Pipeline, node_id: str, current_user_id: str) - @console_ns.route("/rag/pipelines//workflows/draft/system-variables") class RagPipelineSystemVariableCollectionApi(Resource): - @console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model) + @console_ns.response( + 200, + "System variables retrieved successfully", + console_ns.models[WorkflowDraftVariableListResponse.__name__], + ) @_api_prerequisite - @marshal_with(workflow_draft_variable_list_model) def get(self, current_user: Account, pipeline: Pipeline): - return _get_variable_list(pipeline, SYSTEM_VARIABLE_NODE_ID, current_user.id) + return WorkflowDraftVariableListResponse.from_workflow_draft_variable_list( + _get_variable_list(pipeline, SYSTEM_VARIABLE_NODE_ID, current_user.id) + ).model_dump(mode="json") @console_ns.route("/rag/pipelines//workflows/draft/environment-variables") @@ -339,7 +366,7 @@ class RagPipelineEnvironmentVariableCollectionApi(Resource): @console_ns.response( 200, "Environment variables retrieved successfully", - console_ns.models[EnvironmentVariableListResponse.__name__], + console_ns.models[RagPipelineEnvironmentVariableListResponse.__name__], ) @_api_prerequisite def get(self, _current_user: Account, pipeline: Pipeline): @@ -353,22 +380,22 @@ def get(self, _current_user: Account, pipeline: Pipeline): raise DraftWorkflowNotExist() env_vars = workflow.environment_variables - env_vars_list = [] + env_vars_list: list[RagPipelineEnvironmentVariableResponse] = [] for v in env_vars: env_vars_list.append( - { - "id": v.id, - "type": "env", - "name": v.name, - "description": v.description, - "selector": v.selector, - "value_type": v.value_type.value, - "value": v.value, + RagPipelineEnvironmentVariableResponse( + id=v.id, + type="env", + name=v.name, + description=v.description, + selector=list(v.selector), + value_type=v.value_type.value, + value=v.value, # Do not track edited for env vars. - "edited": False, - "visible": True, - "editable": True, - } + edited=False, + visible=True, + editable=True, + ) ) - return {"items": env_vars_list} + return RagPipelineEnvironmentVariableListResponse(items=env_vars_list).model_dump(mode="json") diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index fdc55ea9737196..adf0a0006befdd 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -1,5 +1,6 @@ import json import logging +from collections.abc import Mapping from typing import Any, Literal, cast from uuid import UUID @@ -21,8 +22,6 @@ ) from controllers.console.app.workflow import ( RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE, - DefaultBlockConfigResponse, - DefaultBlockConfigsResponse, WorkflowPaginationResponse, WorkflowResponse, ) @@ -41,6 +40,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.apps.pipeline.pipeline_generator import PipelineGenerator from core.app.entities.app_invoke_entities import InvokeFrom +from core.plugin.entities.plugin_daemon import PluginDatasourceProviderEntity from extensions.ext_database import db from factories import variable_factory from fields.base import ResponseModel @@ -50,9 +50,8 @@ WorkflowRunNodeExecutionResponse, WorkflowRunPaginationResponse, ) -from graphon.model_runtime.utils.encoders import jsonable_encoder from libs import helper -from libs.helper import TimestampField, UUIDStrOrEmpty, dump_response +from libs.helper import UUIDStrOrEmpty, dump_response, to_timestamp from libs.login import login_required from models import Account from models.dataset import Pipeline @@ -72,14 +71,14 @@ class DraftWorkflowSyncPayload(BaseModel): graph: dict[str, Any] hash: str | None = None - environment_variables: list[dict[str, Any]] | None = Field(default=None) - conversation_variables: list[dict[str, Any]] | None = Field(default=None) - rag_pipeline_variables: list[dict[str, Any]] | None = Field(default=None) - features: dict[str, Any] | None = Field(default=None) + environment_variables: list[dict[str, Any]] | None = None + conversation_variables: list[dict[str, Any]] | None = None + rag_pipeline_variables: list[dict[str, Any]] | None = None + features: dict[str, Any] | None = None class NodeRunPayload(BaseModel): - inputs: dict[str, Any] | None = Field(default=None) + inputs: dict[str, Any] | None = None class NodeRunRequiredPayload(BaseModel): @@ -136,12 +135,28 @@ class RagPipelineWorkflowPublishResponse(ResponseModel): created_at: int -class RagPipelineOpaqueResponse(RootModel[Any]): - root: Any +class RagPipelineVariablesResponse(ResponseModel): + # TODO: Replace Any with a response model that mirrors graphon.variables.variables.RAGPipelineVariable. + variables: Any -class RagPipelineStepParametersResponse(ResponseModel): - variables: Any +class DatasourcePluginListResponse(RootModel[list[PluginDatasourceProviderEntity]]): + pass + + +class RagPipelineRecommendedPluginResponse(ResponseModel): + installed_recommended_plugins: list[Mapping[str, object]] = Field( + description="Installed tool provider payloads. Shape follows the tool provider serializer." + ) + uninstalled_recommended_plugins: list[Mapping[str, object]] = Field( + description="Marketplace plugin manifest payloads returned by the marketplace service." + ) + + +class RagPipelineTransformResponse(ResponseModel): + pipeline_id: str + dataset_id: str + status: str register_schema_models( @@ -162,10 +177,10 @@ class RagPipelineStepParametersResponse(ResponseModel): ) register_response_schema_models( console_ns, - DefaultBlockConfigResponse, - DefaultBlockConfigsResponse, - RagPipelineOpaqueResponse, - RagPipelineStepParametersResponse, + DatasourcePluginListResponse, + RagPipelineRecommendedPluginResponse, + RagPipelineTransformResponse, + RagPipelineVariablesResponse, RagPipelineWorkflowPublishResponse, RagPipelineWorkflowSyncResponse, SimpleResultResponse, @@ -254,17 +269,16 @@ def post(self, current_user: Account, pipeline: Pipeline): except WorkflowHashNotEqualError: raise DraftWorkflowNotSync() - return { - "result": "success", - "hash": workflow.unique_hash, - "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), - } + return RagPipelineWorkflowSyncResponse( + result="success", + hash=workflow.unique_hash, + updated_at=to_timestamp(workflow.updated_at or workflow.created_at), + ).model_dump(mode="json") @console_ns.route("/rag/pipelines//workflows/draft/iteration/nodes//run") class RagPipelineDraftRunIterationNodeApi(Resource): @console_ns.expect(console_ns.models[NodeRunPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -284,6 +298,7 @@ def post(self, current_user: Account, pipeline: Pipeline, node_id: str): pipeline=pipeline, user=current_user, node_id=node_id, args=args, streaming=True ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -299,7 +314,6 @@ def post(self, current_user: Account, pipeline: Pipeline, node_id: str): @console_ns.route("/rag/pipelines//workflows/draft/loop/nodes//run") class RagPipelineDraftRunLoopNodeApi(Resource): @console_ns.expect(console_ns.models[NodeRunPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -319,6 +333,7 @@ def post(self, current_user: Account, pipeline: Pipeline, node_id: str): pipeline=pipeline, user=current_user, node_id=node_id, args=args, streaming=True ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -334,7 +349,6 @@ def post(self, current_user: Account, pipeline: Pipeline, node_id: str): @console_ns.route("/rag/pipelines//workflows/draft/run") class DraftRagPipelineRunApi(Resource): @console_ns.expect(console_ns.models[DraftWorkflowRunPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -358,6 +372,7 @@ def post(self, current_user: Account, pipeline: Pipeline): streaming=True, ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except InvokeRateLimitError as ex: raise InvokeRateLimitHttpError(ex.description) @@ -366,7 +381,6 @@ def post(self, current_user: Account, pipeline: Pipeline): @console_ns.route("/rag/pipelines//workflows/published/run") class PublishedRagPipelineRunApi(Resource): @console_ns.expect(console_ns.models[PublishedWorkflowRunPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -391,6 +405,7 @@ def post(self, current_user: Account, pipeline: Pipeline): streaming=streaming, ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except InvokeRateLimitError as ex: raise InvokeRateLimitHttpError(ex.description) @@ -399,7 +414,6 @@ def post(self, current_user: Account, pipeline: Pipeline): @console_ns.route("/rag/pipelines//workflows/published/datasource/nodes//run") class RagPipelinePublishedDatasourceNodeRunApi(Resource): @console_ns.expect(console_ns.models[DatasourceNodeRunPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -414,6 +428,7 @@ def post(self, current_user: Account, pipeline: Pipeline, node_id: str): payload = DatasourceNodeRunPayload.model_validate(console_ns.payload or {}) rag_pipeline_service = RagPipelineService() + # response-contract:ignore compact_generate_response return helper.compact_generate_response( PipelineGenerator.convert_to_event_stream( rag_pipeline_service.run_datasource_workflow_node( @@ -432,7 +447,6 @@ def post(self, current_user: Account, pipeline: Pipeline, node_id: str): @console_ns.route("/rag/pipelines//workflows/draft/datasource/nodes//run") class RagPipelineDraftDatasourceNodeRunApi(Resource): @console_ns.expect(console_ns.models[DatasourceNodeRunPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) @setup_required @login_required @edit_permission_required @@ -447,6 +461,7 @@ def post(self, current_user: Account, pipeline: Pipeline, node_id: str): payload = DatasourceNodeRunPayload.model_validate(console_ns.payload or {}) rag_pipeline_service = RagPipelineService() + # response-contract:ignore compact_generate_response return helper.compact_generate_response( PipelineGenerator.convert_to_event_stream( rag_pipeline_service.run_datasource_workflow_node( @@ -492,9 +507,7 @@ def post(self, current_user: Account, pipeline: Pipeline, node_id: str): if workflow_node_execution is None: raise ValueError("Workflow node execution not found") - return WorkflowRunNodeExecutionResponse.model_validate( - workflow_node_execution, from_attributes=True - ).model_dump(mode="json") + return dump_response(WorkflowRunNodeExecutionResponse, workflow_node_execution) @console_ns.route("/rag/pipelines//workflow-runs/tasks//stop") @@ -513,7 +526,7 @@ def post(self, current_user: Account, pipeline: Pipeline, task_id: str): """ AppQueueManager.set_stop_flag(task_id, InvokeFrom.DEBUGGER, current_user.id) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/rag/pipelines//workflows/publish") @@ -567,20 +580,18 @@ def post(self, current_user: Account, pipeline: Pipeline): pipeline.is_published = True pipeline.workflow_id = workflow.id db.session.commit() - workflow_created_at = TimestampField().format(workflow.created_at) + workflow_created_at = to_timestamp(workflow.created_at) - return { - "result": "success", - "created_at": workflow_created_at, - } + return RagPipelineWorkflowPublishResponse(result="success", created_at=workflow_created_at).model_dump( + mode="json" + ) @console_ns.route("/rag/pipelines//workflows/default-workflow-block-configs") class DefaultRagPipelineBlockConfigsApi(Resource): @console_ns.response( 200, - "Default block configs retrieved successfully", - console_ns.models[DefaultBlockConfigsResponse.__name__], + "Default workflow block configurations retrieved successfully", ) @setup_required @login_required @@ -602,8 +613,7 @@ class DefaultRagPipelineBlockConfigApi(Resource): @console_ns.doc(params=query_params_from_model(DefaultBlockConfigQuery)) @console_ns.response( 200, - "Default block config retrieved successfully", - console_ns.models[DefaultBlockConfigResponse.__name__], + "Default workflow block configuration retrieved successfully", ) @setup_required @login_required @@ -631,13 +641,13 @@ def get(self, pipeline: Pipeline, block_type: str): @console_ns.route("/rag/pipelines//workflows") class PublishedAllRagPipelineApi(Resource): - @console_ns.doc(params=query_params_from_model(WorkflowListQuery)) @console_ns.response( 200, "Published workflows retrieved successfully", console_ns.models[WorkflowPaginationResponse.__name__], ) @console_ns.response(403, "Permission denied") + @console_ns.doc(params=query_params_from_model(WorkflowListQuery)) @setup_required @login_required @account_initialization_required @@ -671,14 +681,15 @@ def get(self, current_user: Account, pipeline: Pipeline): named_only=named_only, ) - return WorkflowPaginationResponse.model_validate( + return dump_response( + WorkflowPaginationResponse, { "items": workflows, "page": page, "limit": limit, "has_more": has_more, - } - ).model_dump(mode="json") + }, + ) @console_ns.route("/rag/pipelines//workflows//restore") @@ -706,11 +717,11 @@ def post(self, current_user: Account, pipeline: Pipeline, workflow_id: str): except WorkflowNotFoundError as exc: raise NotFound(str(exc)) from exc - return { - "result": "success", - "hash": workflow.unique_hash, - "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), - } + return RagPipelineWorkflowSyncResponse( + result="success", + hash=workflow.unique_hash, + updated_at=to_timestamp(workflow.updated_at or workflow.created_at), + ).model_dump(mode="json") @console_ns.route("/rag/pipelines//workflows/") @@ -784,13 +795,17 @@ def delete(self, pipeline: Pipeline, workflow_id: str): except ValueError as e: raise NotFound(str(e)) - return None, 204 + return "", 204 @console_ns.route("/rag/pipelines//workflows/published/processing/parameters") class PublishedRagPipelineSecondStepApi(Resource): @console_ns.doc(params=query_params_from_model(NodeIdQuery)) - @console_ns.response(200, "Success", console_ns.models[RagPipelineStepParametersResponse.__name__]) + @console_ns.response( + 200, + "Second step parameters retrieved successfully", + console_ns.models[RagPipelineVariablesResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -805,15 +820,17 @@ def get(self, pipeline: Pipeline): node_id = query.node_id rag_pipeline_service = RagPipelineService() variables = rag_pipeline_service.get_second_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=False) - return { - "variables": variables, - } + return dump_response(RagPipelineVariablesResponse, {"variables": variables}) @console_ns.route("/rag/pipelines//workflows/published/pre-processing/parameters") class PublishedRagPipelineFirstStepApi(Resource): @console_ns.doc(params=query_params_from_model(NodeIdQuery)) - @console_ns.response(200, "Success", console_ns.models[RagPipelineStepParametersResponse.__name__]) + @console_ns.response( + 200, + "First step parameters retrieved successfully", + console_ns.models[RagPipelineVariablesResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -828,15 +845,17 @@ def get(self, pipeline: Pipeline): node_id = query.node_id rag_pipeline_service = RagPipelineService() variables = rag_pipeline_service.get_first_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=False) - return { - "variables": variables, - } + return dump_response(RagPipelineVariablesResponse, {"variables": variables}) @console_ns.route("/rag/pipelines//workflows/draft/pre-processing/parameters") class DraftRagPipelineFirstStepApi(Resource): @console_ns.doc(params=query_params_from_model(NodeIdQuery)) - @console_ns.response(200, "Success", console_ns.models[RagPipelineStepParametersResponse.__name__]) + @console_ns.response( + 200, + "First step parameters retrieved successfully", + console_ns.models[RagPipelineVariablesResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -851,15 +870,17 @@ def get(self, pipeline: Pipeline): node_id = query.node_id rag_pipeline_service = RagPipelineService() variables = rag_pipeline_service.get_first_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=True) - return { - "variables": variables, - } + return dump_response(RagPipelineVariablesResponse, {"variables": variables}) @console_ns.route("/rag/pipelines//workflows/draft/processing/parameters") class DraftRagPipelineSecondStepApi(Resource): @console_ns.doc(params=query_params_from_model(NodeIdQuery)) - @console_ns.response(200, "Success", console_ns.models[RagPipelineStepParametersResponse.__name__]) + @console_ns.response( + 200, + "Second step parameters retrieved successfully", + console_ns.models[RagPipelineVariablesResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -875,9 +896,7 @@ def get(self, pipeline: Pipeline): rag_pipeline_service = RagPipelineService() variables = rag_pipeline_service.get_second_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=True) - return { - "variables": variables, - } + return dump_response(RagPipelineVariablesResponse, {"variables": variables}) @console_ns.route("/rag/pipelines//workflow-runs") @@ -910,7 +929,7 @@ def get(self, pipeline: Pipeline): rag_pipeline_service = RagPipelineService() result = rag_pipeline_service.get_rag_pipeline_paginate_workflow_runs(pipeline=pipeline, args=args) - return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json") + return dump_response(WorkflowRunPaginationResponse, result) @console_ns.route("/rag/pipelines//workflow-runs/") @@ -935,7 +954,7 @@ def get(self, pipeline: Pipeline, run_id: UUID): if workflow_run is None: raise NotFound("Workflow run not found") - return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json") + return dump_response(WorkflowRunDetailResponse, workflow_run) @console_ns.route("/rag/pipelines//workflow-runs//node-executions") @@ -964,20 +983,25 @@ def get(self, current_user: Account, pipeline: Pipeline, run_id: UUID): user=user, ) - return WorkflowRunNodeExecutionListResponse.model_validate( - {"data": node_executions}, from_attributes=True - ).model_dump(mode="json") + return dump_response(WorkflowRunNodeExecutionListResponse, {"data": node_executions}) @console_ns.route("/rag/pipelines/datasource-plugins") class DatasourceListApi(Resource): - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Datasource plugins retrieved successfully", + console_ns.models[DatasourcePluginListResponse.__name__], + ) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str): - return jsonable_encoder(RagPipelineManageService.list_rag_pipeline_datasources(current_tenant_id)) + return dump_response( + DatasourcePluginListResponse, + RagPipelineManageService.list_rag_pipeline_datasources(current_tenant_id), + ) @console_ns.route("/rag/pipelines//workflows/draft/nodes//last-run") @@ -1003,12 +1027,16 @@ def get(self, pipeline: Pipeline, node_id: str): ) if node_exec is None: raise NotFound("last run not found") - return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json") + return dump_response(WorkflowRunNodeExecutionResponse, node_exec) @console_ns.route("/rag/pipelines/transform/datasets/") class RagPipelineTransformApi(Resource): - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Dataset transformed successfully", + console_ns.models[RagPipelineTransformResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -1020,7 +1048,7 @@ def post(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) rag_pipeline_transform_service = RagPipelineTransformService() result = rag_pipeline_transform_service.transform_dataset(dataset_id_str, db.session) - return result + return dump_response(RagPipelineTransformResponse, result) @console_ns.route("/rag/pipelines//workflows/draft/datasource/variables-inspect") @@ -1050,15 +1078,17 @@ def post(self, current_user: Account, pipeline: Pipeline): args=args, current_user=current_user, ) - return WorkflowRunNodeExecutionResponse.model_validate( - workflow_node_execution, from_attributes=True - ).model_dump(mode="json") + return dump_response(WorkflowRunNodeExecutionResponse, workflow_node_execution) @console_ns.route("/rag/pipelines/recommended-plugins") class RagPipelineRecommendedPluginApi(Resource): @console_ns.doc(params=query_params_from_model(RagPipelineRecommendedPluginQuery)) - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Recommended plugins retrieved successfully", + console_ns.models[RagPipelineRecommendedPluginResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -1069,4 +1099,4 @@ def get(self, current_tenant_id: str, current_user: Account): rag_pipeline_service = RagPipelineService() recommended_plugins = rag_pipeline_service.get_recommended_plugins(query.type, current_user, current_tenant_id) - return recommended_plugins + return dump_response(RagPipelineRecommendedPluginResponse, recommended_plugins) diff --git a/api/controllers/console/explore/completion.py b/api/controllers/console/explore/completion.py index 729be6a909b40a..eb108f6760c537 100644 --- a/api/controllers/console/explore/completion.py +++ b/api/controllers/console/explore/completion.py @@ -6,7 +6,7 @@ from werkzeug.exceptions import InternalServerError, NotFound import services -from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse +from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console.app.error import ( AppUnavailableError, @@ -73,7 +73,7 @@ def normalize_uuid(cls, value: str | UUID | None) -> str | None: register_schema_models(console_ns, CompletionMessageExplorePayload, ChatMessagePayload) -register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse) +register_response_schema_models(console_ns, SimpleResultResponse) # define completion api for user @@ -83,7 +83,7 @@ def normalize_uuid(cls, value: str | UUID | None) -> str | None: ) class CompletionApi(InstalledAppResource): @console_ns.expect(console_ns.models[CompletionMessageExplorePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Success") @with_current_user def post(self, current_user: Account, installed_app: InstalledApp): app_model = installed_app.app @@ -106,6 +106,7 @@ def post(self, current_user: Account, installed_app: InstalledApp): app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=streaming ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -150,7 +151,7 @@ def post(self, current_user_id: str, installed_app: InstalledApp, task_id: str): app_mode=AppMode.value_of(app_model.mode), ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route( @@ -159,7 +160,7 @@ def post(self, current_user_id: str, installed_app: InstalledApp, task_id: str): ) class ChatApi(InstalledAppResource): @console_ns.expect(console_ns.models[ChatMessagePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Success") @with_current_user def post(self, current_user: Account, installed_app: InstalledApp): app_model = installed_app.app @@ -182,6 +183,7 @@ def post(self, current_user: Account, installed_app: InstalledApp): app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -229,4 +231,4 @@ def post(self, current_user_id: str, installed_app: InstalledApp, task_id: str): app_mode=app_mode, ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 diff --git a/api/controllers/console/explore/message.py b/api/controllers/console/explore/message.py index 2be550b2f28fc5..8f2489557097f5 100644 --- a/api/controllers/console/explore/message.py +++ b/api/controllers/console/explore/message.py @@ -7,7 +7,6 @@ from werkzeug.exceptions import InternalServerError, NotFound from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery -from controllers.common.fields import GeneratedAppResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console.app.error import ( AppMoreLikeThisDisabledError, @@ -59,7 +58,6 @@ class MoreLikeThisQuery(BaseModel): register_schema_models(console_ns, MessageListQuery, MessageFeedbackPayload, MoreLikeThisQuery) register_response_schema_models( console_ns, - GeneratedAppResponse, ExploreMessageInfiniteScrollPagination, ResultResponse, SuggestedQuestionsResponse, @@ -142,7 +140,7 @@ def post(self, current_user: Account, installed_app: InstalledApp, message_id: U ) class MessageMoreLikeThisApi(InstalledAppResource): @console_ns.doc(params=query_params_from_model(MoreLikeThisQuery)) - @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Success") @with_current_user def get(self, current_user: Account, installed_app: InstalledApp, message_id: UUID): app_model = installed_app.app @@ -165,6 +163,7 @@ def get(self, current_user: Account, installed_app: InstalledApp, message_id: UU invoke_from=InvokeFrom.EXPLORE, streaming=streaming, ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except MessageNotExistsError: raise NotFound("Message Not Exists.") diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index 6aef9129780b8b..fd614ece87f107 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -1,28 +1,24 @@ import logging -from typing import Any, Literal, cast +from typing import Literal from flask import request -from flask_restx import Resource, fields, marshal, marshal_with +from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy import select from werkzeug.exceptions import Forbidden, InternalServerError, NotFound import services -from controllers.common.fields import ( - AudioBinaryResponse, - AudioTranscriptResponse, - GeneratedAppResponse, - SimpleResultResponse, -) +from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse, SimpleResultResponse from controllers.common.fields import Parameters as ParametersResponse from controllers.common.fields import Site as SiteResponse from controllers.common.schema import ( - get_or_create_model, query_params_from_model, + query_params_from_request, register_response_schema_models, register_schema_models, ) from controllers.console import console_ns +from controllers.console.app.app import AppDetailWithSite from controllers.console.app.error import ( AppUnavailableError, AudioTooLargeError, @@ -36,6 +32,7 @@ ProviderQuotaExceededError, UnsupportedAudioTypeError, ) +from controllers.console.app.workflow import WorkflowResponse from controllers.console.app.wraps import get_app_model_with_trial from controllers.console.explore.error import ( AppSuggestedQuestionsAfterAnswerDisabledError, @@ -56,26 +53,13 @@ ) from extensions.ext_database import db from extensions.ext_redis import redis_client -from fields.app_fields import ( - app_detail_fields_with_site, - deleted_tool_fields, - model_config_fields, - site_fields, - tag_fields, -) -from fields.dataset_fields import dataset_fields -from fields.member_fields import simple_account_fields +from fields.base import ResponseModel +from fields.dataset_fields import DatasetDetailResponse from fields.message_fields import SuggestedQuestionsResponse -from fields.workflow_fields import ( - conversation_variable_fields, - pipeline_variable_fields, - workflow_fields, - workflow_partial_fields, -) from graphon.graph_engine.manager import GraphEngineManager from graphon.model_runtime.errors.invoke import InvokeError from libs import helper -from libs.helper import uuid_value +from libs.helper import dump_response, uuid_value from models import Account from models.account import TenantStatus from models.model import AppMode, Site @@ -102,57 +86,42 @@ logger = logging.getLogger(__name__) -model_config_model = get_or_create_model("TrialAppModelConfig", model_config_fields) -workflow_partial_model = get_or_create_model("TrialWorkflowPartial", workflow_partial_fields) -deleted_tool_model = get_or_create_model("TrialDeletedTool", deleted_tool_fields) -tag_model = get_or_create_model("TrialTag", tag_fields) -site_model = get_or_create_model("TrialSite", site_fields) +class TrialDatasetListItemResponse(DatasetDetailResponse): + pass -app_detail_fields_with_site_copy = app_detail_fields_with_site.copy() -app_detail_fields_with_site_copy["model_config"] = fields.Nested( - model_config_model, attribute="app_model_config", allow_null=True -) -app_detail_fields_with_site_copy["workflow"] = fields.Nested(workflow_partial_model, allow_null=True) -app_detail_fields_with_site_copy["deleted_tools"] = fields.List(fields.Nested(deleted_tool_model)) -app_detail_fields_with_site_copy["tags"] = fields.List(fields.Nested(tag_model)) -app_detail_fields_with_site_copy["site"] = fields.Nested(site_model) -app_detail_with_site_model = get_or_create_model("TrialAppDetailWithSite", app_detail_fields_with_site_copy) - -simple_account_model = get_or_create_model("TrialSimpleAccount", simple_account_fields) -conversation_variable_model = get_or_create_model("TrialConversationVariable", conversation_variable_fields) -pipeline_variable_model = get_or_create_model("TrialPipelineVariable", pipeline_variable_fields) - -workflow_fields_copy = workflow_fields.copy() -workflow_fields_copy["created_by"] = fields.Nested(simple_account_model, attribute="created_by_account") -workflow_fields_copy["updated_by"] = fields.Nested( - simple_account_model, attribute="updated_by_account", allow_null=True -) -workflow_fields_copy["conversation_variables"] = fields.List(fields.Nested(conversation_variable_model)) -workflow_fields_copy["rag_pipeline_variables"] = fields.List(fields.Nested(pipeline_variable_model)) -workflow_model = get_or_create_model("TrialWorkflow", workflow_fields_copy) - -dataset_model = get_or_create_model("TrialDataset", dataset_fields) -dataset_list_model = get_or_create_model( - "TrialDatasetList", - { - "data": fields.List(fields.Nested(dataset_model)), - "has_more": fields.Boolean, - "limit": fields.Integer, - "total": fields.Integer, - "page": fields.Integer, - }, + +class TrialDatasetListResponse(ResponseModel): + data: list[TrialDatasetListItemResponse] + has_more: bool + limit: int + total: int + page: int + + +register_response_schema_models( + console_ns, + ParametersResponse, + AppDetailWithSite, + AudioBinaryResponse, + AudioTranscriptResponse, + SimpleResultResponse, + SiteResponse, + SuggestedQuestionsResponse, + TrialDatasetListItemResponse, + TrialDatasetListResponse, + WorkflowResponse, ) class WorkflowRunRequest(BaseModel): inputs: dict - files: list | None = Field(default=None) + files: list | None = None class ChatRequest(BaseModel): inputs: dict query: str - files: list | None = Field(default=None) + files: list | None = None conversation_id: str | None = None parent_message_id: str | None = None retriever_from: str = "explore_app" @@ -168,7 +137,7 @@ class TextToSpeechRequest(BaseModel): class CompletionRequest(BaseModel): inputs: dict query: str = "" - files: list | None = Field(default=None) + files: list | None = None response_mode: Literal["blocking", "streaming"] | None = None retriever_from: str = "explore_app" @@ -187,23 +156,13 @@ class TrialDatasetListQuery(BaseModel): CompletionRequest, TrialDatasetListQuery, ) -register_response_schema_models( - console_ns, - ParametersResponse, - AudioBinaryResponse, - AudioTranscriptResponse, - GeneratedAppResponse, - SimpleResultResponse, - SiteResponse, - SuggestedQuestionsResponse, -) class TrialAppWorkflowRunApi(TrialAppResource): @trial_feature_enable - @console_ns.expect(console_ns.models[WorkflowRunRequest.__name__]) - @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) @with_current_user + @console_ns.expect(console_ns.models[WorkflowRunRequest.__name__]) + @console_ns.response(200, "Success") def post(self, current_user: Account, trial_app): """ Run workflow @@ -224,6 +183,7 @@ def post(self, current_user: Account, trial_app): app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True ) RecommendedAppService.add_trial_app_record(db.session, app_id, user_id) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) @@ -263,12 +223,12 @@ def post(self, trial_app, task_id: str): # New graph engine command channel mechanism GraphEngineManager(redis_client).send_stop_command(task_id) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") class TrialChatApi(TrialAppResource): @console_ns.expect(console_ns.models[ChatRequest.__name__]) - @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Success") @trial_feature_enable @with_current_user def post(self, current_user: Account, trial_app): @@ -297,6 +257,7 @@ def post(self, current_user: Account, trial_app): app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True ) RecommendedAppService.add_trial_app_record(db.session, app_id, user_id) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -355,7 +316,7 @@ def get(self, current_user: Account, trial_app, message_id): logger.exception("internal server error.") raise InternalServerError() - return {"data": questions} + return dump_response(SuggestedQuestionsResponse, {"data": questions}) class TrialChatAudioApi(TrialAppResource): @@ -374,7 +335,7 @@ def post(self, current_user: Account, trial_app): response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=None) RecommendedAppService.add_trial_app_record(db.session, app_id, user_id) - return response + return dump_response(AudioTranscriptResponse, response) except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") raise AppUnavailableError() @@ -427,6 +388,7 @@ def post(self, current_user: Account, trial_app): message_id=message_id, ) RecommendedAppService.add_trial_app_record(db.session, app_id, user_id) + # response-contract:ignore binary response return response except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") @@ -456,7 +418,7 @@ def post(self, current_user: Account, trial_app): class TrialCompletionApi(TrialAppResource): @console_ns.expect(console_ns.models[CompletionRequest.__name__]) - @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Success") @trial_feature_enable @with_current_user def post(self, current_user: Account, trial_app): @@ -480,6 +442,7 @@ def post(self, current_user: Account, trial_app): ) RecommendedAppService.add_trial_app_record(db.session, app_id, user_id) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -557,50 +520,49 @@ def get(self, app_model): class AppApi(Resource): - @console_ns.response(200, "Success", app_detail_with_site_model) @get_app_model_with_trial(None) - @marshal_with(app_detail_with_site_model) + @console_ns.response(200, "App detail retrieved successfully", console_ns.models[AppDetailWithSite.__name__]) def get(self, app_model): """Get app detail""" app_service = AppService() app_model = app_service.get_app(app_model) - return app_model + return dump_response(AppDetailWithSite, app_model) class AppWorkflowApi(Resource): - @console_ns.response(200, "Success", workflow_model) @get_app_model_with_trial(None) - @marshal_with(workflow_model) + @console_ns.response(200, "Workflow detail retrieved successfully", console_ns.models[WorkflowResponse.__name__]) def get(self, app_model): """Get workflow detail""" if not app_model.workflow_id: raise AppUnavailableError() workflow = db.session.get(Workflow, app_model.workflow_id) - return workflow + return dump_response(WorkflowResponse, workflow) class DatasetListApi(Resource): @console_ns.doc(params=query_params_from_model(TrialDatasetListQuery)) - @console_ns.response(200, "Success", dataset_list_model) + @console_ns.response(200, "Success", console_ns.models[TrialDatasetListResponse.__name__]) @get_app_model_with_trial(None) def get(self, app_model): - page = request.args.get("page", default=1, type=int) - limit = request.args.get("limit", default=20, type=int) - ids = request.args.getlist("ids") + query = query_params_from_request( + TrialDatasetListQuery, + list_fields=("ids",), + use_defaults_for_malformed_ints=True, + ) tenant_id = app_model.tenant_id - if ids: - datasets, total = DatasetService.get_datasets_by_ids(ids, tenant_id) + if query.ids: + datasets, total = DatasetService.get_datasets_by_ids(query.ids, tenant_id) else: raise NeedAddIdsError() - data = cast(list[dict[str, Any]], marshal(datasets, dataset_fields)) - - response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page} - return response + return TrialDatasetListResponse( + data=datasets, has_more=len(datasets) == query.limit, limit=query.limit, total=total or 0, page=query.page + ).model_dump(mode="json") console_ns.add_resource(TrialChatApi, "/trial-apps//chat-messages", endpoint="trial_app_chat_completion") diff --git a/api/controllers/console/explore/workflow.py b/api/controllers/console/explore/workflow.py index f4e9e3cd7ef607..ad6e23b24d1935 100644 --- a/api/controllers/console/explore/workflow.py +++ b/api/controllers/console/explore/workflow.py @@ -3,7 +3,7 @@ from werkzeug.exceptions import InternalServerError from controllers.common.controller_schemas import WorkflowRunPayload -from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse +from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_model from controllers.console.app.error import ( CompletionRequestError, @@ -36,13 +36,13 @@ logger = logging.getLogger(__name__) register_schema_model(console_ns, WorkflowRunPayload) -register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse) +register_response_schema_models(console_ns, SimpleResultResponse) @console_ns.route("/installed-apps//workflows/run") class InstalledAppWorkflowRunApi(InstalledAppResource): @console_ns.expect(console_ns.models[WorkflowRunPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(200, "Success") @with_current_user def post(self, current_user: Account, installed_app: InstalledApp): """ @@ -62,6 +62,7 @@ def post(self, current_user: Account, installed_app: InstalledApp): app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) @@ -101,4 +102,4 @@ def post(self, installed_app: InstalledApp, task_id: str): # New graph engine command channel mechanism GraphEngineManager(redis_client).send_stop_command(task_id) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") diff --git a/api/controllers/console/extension.py b/api/controllers/console/extension.py index ec1e01dc460ff3..4b149b9c08d974 100644 --- a/api/controllers/console/extension.py +++ b/api/controllers/console/extension.py @@ -4,18 +4,18 @@ from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field, TypeAdapter, field_validator +from pydantic import BaseModel, Field, RootModel, field_validator from constants import HIDDEN_VALUE from extensions.ext_database import db from fields.base import ResponseModel -from libs.helper import to_timestamp +from libs.helper import dump_response, to_timestamp from libs.login import login_required from models.api_based_extension import APIBasedExtension from services.api_based_extension_service import APIBasedExtensionService from services.code_based_extension_service import CodeBasedExtensionService -from ..common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0, query_params_from_model, register_schema_models +from ..common.schema import query_params_from_model, register_response_schema_models, register_schema_models from . import console_ns from .wraps import account_initialization_required, setup_required, with_current_tenant_id @@ -61,36 +61,21 @@ def _normalize_created_at(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) +class APIBasedExtensionListResponse(RootModel[list[APIBasedExtensionResponse]]): + pass + + register_schema_models( console_ns, CodeBasedExtensionQuery, APIBasedExtensionPayload, +) +register_response_schema_models( + console_ns, CodeBasedExtensionResponse, APIBasedExtensionResponse, + APIBasedExtensionListResponse, ) -console_ns.schema_model( - "APIBasedExtensionListResponse", - TypeAdapter(list[APIBasedExtensionResponse]).json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0), -) - - -def _serialize_api_based_extension(extension: APIBasedExtension) -> dict[str, Any]: - return APIBasedExtensionResponse.model_validate(extension, from_attributes=True).model_dump(mode="json") - - -def _serialize_saved_api_based_extension(extension: APIBasedExtension, api_key: str) -> dict[str, Any]: - """Serialize a saved extension with the plaintext key used for response masking only. - - APIBasedExtensionService.save mutates the ORM object to hold the encrypted token before returning it. The response - contract, however, should match list/detail responses, where api_key is masked from the decrypted token. - """ - return APIBasedExtensionResponse( - id=extension.id, - name=extension.name, - api_endpoint=extension.api_endpoint, - api_key=api_key, - created_at=to_timestamp(extension.created_at), - ).model_dump(mode="json") @console_ns.route("/code-based-extension") @@ -119,16 +104,16 @@ def get(self): class APIBasedExtensionAPI(Resource): @console_ns.doc("get_api_based_extensions") @console_ns.doc(description="Get all API-based extensions for current tenant") - @console_ns.response(200, "Success", console_ns.models["APIBasedExtensionListResponse"]) + @console_ns.response(200, "Success", console_ns.models[APIBasedExtensionListResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str): - return [ - _serialize_api_based_extension(extension) - for extension in APIBasedExtensionService.get_all_by_tenant_id(db.session(), current_tenant_id) - ] + return dump_response( + APIBasedExtensionListResponse, + APIBasedExtensionService.get_all_by_tenant_id(db.session(), current_tenant_id), + ) @console_ns.doc("create_api_based_extension") @console_ns.doc(description="Create a new API-based extension") @@ -148,12 +133,14 @@ def post(self, current_tenant_id: str): api_key=payload.api_key, ) - return ( - _serialize_saved_api_based_extension( - APIBasedExtensionService.save(db.session(), extension_data), payload.api_key - ), - 201, - ) + extension = APIBasedExtensionService.save(db.session(), extension_data) + return APIBasedExtensionResponse( + id=extension.id, + name=extension.name, + api_endpoint=extension.api_endpoint, + api_key=payload.api_key, + created_at=to_timestamp(extension.created_at), + ).model_dump(mode="json"), 201 @console_ns.route("/api-based-extension/") @@ -169,8 +156,9 @@ class APIBasedExtensionDetailAPI(Resource): def get(self, current_tenant_id: str, id: UUID): api_based_extension_id = str(id) - return _serialize_api_based_extension( - APIBasedExtensionService.get_with_tenant_id(db.session(), current_tenant_id, api_based_extension_id) + return dump_response( + APIBasedExtensionResponse, + APIBasedExtensionService.get_with_tenant_id(db.session(), current_tenant_id, api_based_extension_id), ) @console_ns.doc("update_api_based_extension") @@ -199,10 +187,14 @@ def post(self, current_tenant_id: str, id: UUID): extension_data_from_db.api_key = payload.api_key api_key_for_response = payload.api_key - return _serialize_saved_api_based_extension( - APIBasedExtensionService.save(db.session(), extension_data_from_db), - api_key_for_response, - ) + APIBasedExtensionService.save(db.session(), extension_data_from_db) + return APIBasedExtensionResponse( + id=extension_data_from_db.id, + name=extension_data_from_db.name, + api_endpoint=extension_data_from_db.api_endpoint, + api_key=api_key_for_response, + created_at=to_timestamp(extension_data_from_db.created_at), + ).model_dump(mode="json") @console_ns.doc("delete_api_based_extension") @console_ns.doc(description="Delete API-based extension") diff --git a/api/controllers/console/files.py b/api/controllers/console/files.py index 5197120c135b7b..a81be1e77f0906 100644 --- a/api/controllers/console/files.py +++ b/api/controllers/console/files.py @@ -27,6 +27,7 @@ ) from extensions.ext_database import db from fields.file_fields import FileResponse, UploadConfig +from libs.helper import dump_response from libs.login import login_required from models import Account from services.file_service import FileService @@ -100,8 +101,7 @@ def post(self, current_user: Account): except services.errors.file.BlockedFileExtensionError as blocked_extension_error: raise BlockedFileExtensionError(blocked_extension_error.description) - response = FileResponse.model_validate(upload_file, from_attributes=True) - return response.model_dump(mode="json"), 201 + return dump_response(FileResponse, upload_file), 201 @console_ns.route("/files//preview") @@ -114,7 +114,7 @@ class FilePreviewApi(Resource): def get(self, current_tenant_id: str, file_id: UUID): file_id_str = str(file_id) text = FileService(db.engine).get_file_preview(file_id_str, current_tenant_id) - return {"content": text} + return TextContentResponse(content=text).model_dump(mode="json") @console_ns.route("/files/support-type") @@ -124,4 +124,4 @@ class FileSupportTypeApi(Resource): @account_initialization_required @console_ns.response(200, "Success", console_ns.models[AllowedExtensionsResponse.__name__]) def get(self): - return {"allowed_extensions": list(DOCUMENT_EXTENSIONS)} + return AllowedExtensionsResponse(allowed_extensions=list(DOCUMENT_EXTENSIONS)).model_dump(mode="json") diff --git a/api/controllers/console/snippets/payloads.py b/api/controllers/console/snippets/payloads.py index 70730e754decfa..631a35eea32aec 100644 --- a/api/controllers/console/snippets/payloads.py +++ b/api/controllers/console/snippets/payloads.py @@ -145,6 +145,8 @@ class PublishWorkflowPayload(BaseModel): """Payload for publishing snippet workflow.""" knowledge_base_setting: dict[str, Any] | None = Field(default=None) + marked_name: str | None = Field(default=None, max_length=20) + marked_comment: str | None = Field(default=None, max_length=100) class SnippetImportPayload(BaseModel): diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index 0b8dc264a68189..17416ee8610e32 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -8,20 +8,18 @@ from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import BadRequest, InternalServerError, NotFound -from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse +from controllers.common.fields import EventStreamResponse, SimpleResultResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.error import DraftWorkflowNotExist, DraftWorkflowNotSync from controllers.console.app.workflow import ( RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE, - DefaultBlockConfigsResponse, + PublishWorkflowResponse, + SyncDraftWorkflowResponse, WorkflowPaginationResponse, - WorkflowPublishResponse, WorkflowResponse, - WorkflowRestoreResponse, ) from controllers.console.snippets.payloads import ( - PublishWorkflowPayload, SnippetDraftNodeRunPayload, SnippetDraftRunPayload, SnippetDraftSyncPayload, @@ -43,6 +41,7 @@ from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from extensions.ext_redis import redis_client +from fields.base import ResponseModel from fields.workflow_run_fields import ( WorkflowRunDetailResponse, WorkflowRunNodeExecutionListResponse, @@ -51,7 +50,7 @@ ) from graphon.graph_engine.manager import GraphEngineManager from libs import helper -from libs.helper import TimestampField +from libs.helper import dump_response, to_timestamp from libs.login import current_account_with_tenant, login_required from models import Account from models.snippet import CustomizedSnippet @@ -76,7 +75,7 @@ class SnippetWorkflowResponse(WorkflowResponse): input_fields: list[dict] = Field(default_factory=list) -class SnippetDraftConfigResponse(BaseModel): +class SnippetDraftConfigResponse(ResponseModel): parallel_depth_limit: int @@ -96,19 +95,17 @@ class SnippetWorkflowPaginationResponse(BaseModel): SnippetLoopNodeRunPayload, SnippetWorkflowListQuery, WorkflowRunQuery, - PublishWorkflowPayload, ) register_response_schema_models( console_ns, - DefaultBlockConfigsResponse, - GeneratedAppResponse, + EventStreamResponse, SimpleResultResponse, SnippetDraftConfigResponse, SnippetWorkflowResponse, SnippetWorkflowPaginationResponse, - WorkflowPublishResponse, + PublishWorkflowResponse, WorkflowPaginationResponse, - WorkflowRestoreResponse, + SyncDraftWorkflowResponse, WorkflowRunPaginationResponse, WorkflowRunDetailResponse, WorkflowRunNodeExecutionListResponse, @@ -175,7 +172,7 @@ def get(self, snippet: CustomizedSnippet): raise DraftWorkflowNotExist() workflow.conversation_variables = [] - response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response = dump_response(SnippetWorkflowResponse, workflow) response["input_fields"] = snippet.input_fields_list return response @@ -184,7 +181,7 @@ def get(self, snippet: CustomizedSnippet): @console_ns.response( 200, "Draft workflow synced successfully", - console_ns.models[WorkflowRestoreResponse.__name__], + console_ns.models[SyncDraftWorkflowResponse.__name__], ) @console_ns.response(400, "Hash mismatch") @setup_required @@ -214,11 +211,11 @@ def post(self, current_user: Account, snippet: CustomizedSnippet): except ValueError as e: return {"message": str(e)}, 400 - return { - "result": "success", - "hash": workflow.unique_hash, - "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), - } + return SyncDraftWorkflowResponse( + result="success", + hash=workflow.unique_hash, + updated_at=to_timestamp(workflow.updated_at or workflow.created_at), + ).model_dump(mode="json") @console_ns.route("/snippets//workflows/draft/config") @@ -237,9 +234,7 @@ class SnippetDraftConfigApi(Resource): @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, snippet: CustomizedSnippet): """Get snippet draft workflow configuration limits.""" - return { - "parallel_depth_limit": 3, - } + return SnippetDraftConfigResponse(parallel_depth_limit=3).model_dump(mode="json") @console_ns.route("/snippets//workflows/publish") @@ -268,13 +263,12 @@ def get(self, snippet: CustomizedSnippet): if not workflow: return None - response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response = dump_response(SnippetWorkflowResponse, workflow) response["input_fields"] = snippet.input_fields_list return response @console_ns.doc("publish_snippet_workflow") - @console_ns.expect(console_ns.models.get(PublishWorkflowPayload.__name__)) - @console_ns.response(200, "Workflow published successfully", console_ns.models[WorkflowPublishResponse.__name__]) + @console_ns.response(200, "Workflow published successfully", console_ns.models[PublishWorkflowResponse.__name__]) @console_ns.response(400, "No draft workflow found") @setup_required @login_required @@ -297,25 +291,18 @@ def post(self, current_user: Account, snippet: CustomizedSnippet): snippet=snippet, account=current_user, ) - workflow_created_at = TimestampField().format(workflow.created_at) + workflow_created_at = to_timestamp(workflow.created_at) session.commit() except ValueError as e: return {"message": str(e)}, 400 - return { - "result": "success", - "created_at": workflow_created_at, - } + return PublishWorkflowResponse(result="success", created_at=workflow_created_at).model_dump(mode="json") @console_ns.route("/snippets//workflows/default-workflow-block-configs") class SnippetDefaultBlockConfigsApi(Resource): @console_ns.doc("get_snippet_default_block_configs") - @console_ns.response( - 200, - "Default block configs retrieved successfully", - console_ns.models[DefaultBlockConfigsResponse.__name__], - ) + @console_ns.response(200, "Default block configs retrieved successfully") @setup_required @login_required @account_initialization_required @@ -377,7 +364,7 @@ class SnippetDraftWorkflowRestoreApi(Resource): @console_ns.doc("restore_snippet_workflow_to_draft") @console_ns.doc(description="Restore a published snippet workflow version into the draft workflow") @console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Published workflow ID"}) - @console_ns.response(200, "Workflow restored successfully", console_ns.models[WorkflowRestoreResponse.__name__]) + @console_ns.response(200, "Workflow restored successfully", console_ns.models[SyncDraftWorkflowResponse.__name__]) @console_ns.response(400, "Source workflow must be published") @console_ns.response(404, "Workflow not found") @setup_required @@ -406,11 +393,11 @@ def post(self, current_user: Account, snippet: CustomizedSnippet, workflow_id: s except ValueError as exc: raise BadRequest(str(exc)) from exc - return { - "result": "success", - "hash": workflow.unique_hash, - "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), - } + return SyncDraftWorkflowResponse( + result="success", + hash=workflow.unique_hash, + updated_at=to_timestamp(workflow.updated_at or workflow.created_at), + ).model_dump(mode="json") @console_ns.route("/snippets//workflow-runs") @@ -442,7 +429,7 @@ def get(self, snippet: CustomizedSnippet): snippet_service = _snippet_service() result = snippet_service.get_snippet_workflow_runs(snippet=snippet, args=args) - return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json") + return dump_response(WorkflowRunPaginationResponse, result) @console_ns.route("/snippets//workflow-runs/") @@ -468,7 +455,7 @@ def get(self, snippet: CustomizedSnippet, run_id): if not workflow_run: raise NotFound("Workflow run not found") - return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json") + return dump_response(WorkflowRunDetailResponse, workflow_run) @console_ns.route("/snippets//workflow-runs//node-executions") @@ -493,9 +480,7 @@ def get(self, snippet: CustomizedSnippet, run_id): run_id=run_id, ) - return WorkflowRunNodeExecutionListResponse.model_validate( - {"data": node_executions}, from_attributes=True - ).model_dump(mode="json") + return dump_response(WorkflowRunNodeExecutionListResponse, {"data": node_executions}) @console_ns.route("/snippets//workflows/draft/nodes//run") @@ -546,9 +531,7 @@ def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str): session_maker=_snippet_session_maker(), ) - return WorkflowRunNodeExecutionResponse.model_validate( - workflow_node_execution, from_attributes=True - ).model_dump(mode="json") + return dump_response(WorkflowRunNodeExecutionResponse, workflow_node_execution) @console_ns.route("/snippets//workflows/draft/nodes//last-run") @@ -584,7 +567,7 @@ def get(self, snippet: CustomizedSnippet, node_id: str): if node_exec is None: raise NotFound("Node last run not found") - return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json") + return dump_response(WorkflowRunNodeExecutionResponse, node_exec) @console_ns.route("/snippets//workflows/draft/iteration/nodes//run") @@ -596,7 +579,7 @@ class SnippetDraftRunIterationNodeApi(Resource): @console_ns.response( 200, "Iteration node run started successfully (SSE stream)", - console_ns.models[GeneratedAppResponse.__name__], + console_ns.models[EventStreamResponse.__name__], ) @console_ns.response(404, "Snippet or draft workflow not found") @setup_required @@ -627,6 +610,7 @@ def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str): session_maker=_snippet_session_maker(), ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except ValueError as e: raise e @@ -644,7 +628,7 @@ class SnippetDraftRunLoopNodeApi(Resource): @console_ns.response( 200, "Loop node run started successfully (SSE stream)", - console_ns.models[GeneratedAppResponse.__name__], + console_ns.models[EventStreamResponse.__name__], ) @console_ns.response(404, "Snippet or draft workflow not found") @setup_required @@ -675,6 +659,7 @@ def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str): session_maker=_snippet_session_maker(), ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except ValueError as e: raise e @@ -690,7 +675,7 @@ class SnippetDraftWorkflowRunApi(Resource): @console_ns.response( 200, "Draft workflow run started successfully (SSE stream)", - console_ns.models[GeneratedAppResponse.__name__], + console_ns.models[EventStreamResponse.__name__], ) @console_ns.response(404, "Snippet or draft workflow not found") @setup_required @@ -722,6 +707,7 @@ def post(self, current_user: Account, snippet: CustomizedSnippet): session_maker=_snippet_session_maker(), ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except ValueError as e: raise e @@ -757,4 +743,4 @@ def post(self, snippet: CustomizedSnippet, task_id: str): # New graph engine command channel mechanism GraphEngineManager(redis_client).send_stop_command(task_id) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") diff --git a/api/controllers/console/snippets/snippet_workflow_draft_variable.py b/api/controllers/console/snippets/snippet_workflow_draft_variable.py index 4befd2596663c9..013a9ad942d6a9 100644 --- a/api/controllers/console/snippets/snippet_workflow_draft_variable.py +++ b/api/controllers/console/snippets/snippet_workflow_draft_variable.py @@ -15,7 +15,7 @@ from typing import Any, Concatenate from flask import Response, request -from flask_restx import Resource, marshal, marshal_with +from flask_restx import Resource from sqlalchemy.orm import Session, sessionmaker from controllers.common.errors import InvalidArgumentError, NotFoundError @@ -23,14 +23,15 @@ from controllers.console import console_ns from controllers.console.app.error import DraftWorkflowNotExist from controllers.console.app.workflow_draft_variable import ( - EnvironmentVariableListResponse, + WorkflowDraftEnvironmentVariableListResponse, + WorkflowDraftEnvironmentVariableResponse, WorkflowDraftVariableListQuery, + WorkflowDraftVariableListResponse, + WorkflowDraftVariableListWithoutValueResponse, + WorkflowDraftVariableResponse, WorkflowDraftVariableUpdatePayload, ensure_variable_access, validate_node_id, - workflow_draft_variable_list_model, - workflow_draft_variable_list_without_value_model, - workflow_draft_variable_model, ) from controllers.console.snippets.snippet_workflow import get_snippet from controllers.console.wraps import ( @@ -101,12 +102,11 @@ class SnippetWorkflowVariableCollectionApi(Resource): @console_ns.response( 200, "Workflow variables retrieved successfully", - workflow_draft_variable_list_without_value_model, + console_ns.models[WorkflowDraftVariableListWithoutValueResponse.__name__], ) @_snippet_draft_var_prerequisite - @marshal_with(workflow_draft_variable_list_without_value_model) @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) - def get(self, current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraftVariableList: + def get(self, current_user: Account, snippet: CustomizedSnippet) -> dict[str, Any]: args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore snippet_service = _snippet_service() @@ -123,7 +123,9 @@ def get(self, current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraf exclude_node_ids=_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS, ) - return workflow_vars + return WorkflowDraftVariableListWithoutValueResponse.from_workflow_draft_variable_list( + workflow_vars + ).model_dump(mode="json") @console_ns.doc("delete_snippet_workflow_variables") @console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)") @@ -132,49 +134,53 @@ def get(self, current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraf @rbac_permission_required( RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False ) - def delete(self, current_user: Account, snippet: CustomizedSnippet) -> Response: + def delete(self, current_user: Account, snippet: CustomizedSnippet) -> tuple[str, int]: draft_var_srv = WorkflowDraftVariableService(session=db.session()) draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id) db.session.commit() - return Response("", 204) + return "", 204 @console_ns.route("/snippets//workflows/draft/nodes//variables") class SnippetNodeVariableCollectionApi(Resource): @console_ns.doc("get_snippet_node_variables") @console_ns.doc(description="Get variables for a specific node (snippet draft workflow)") - @console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model) + @console_ns.response( + 200, + "Node variables retrieved successfully", + console_ns.models[WorkflowDraftVariableListResponse.__name__], + ) @_snippet_draft_var_prerequisite - @marshal_with(workflow_draft_variable_list_model) - def get(self, current_user: Account, snippet: CustomizedSnippet, node_id: str) -> WorkflowDraftVariableList: + def get(self, current_user: Account, snippet: CustomizedSnippet, node_id: str) -> dict[str, Any]: validate_node_id(node_id) with Session(bind=db.engine, expire_on_commit=False) as session: draft_var_srv = WorkflowDraftVariableService(session=session) node_vars = draft_var_srv.list_node_variables(snippet.id, node_id, user_id=current_user.id) - return node_vars + return WorkflowDraftVariableListResponse.from_workflow_draft_variable_list(node_vars).model_dump(mode="json") @console_ns.doc("delete_snippet_node_variables") @console_ns.doc(description="Delete all variables for a specific node (snippet draft workflow)") @console_ns.response(204, "Node variables deleted successfully") @_snippet_draft_var_prerequisite - def delete(self, current_user: Account, snippet: CustomizedSnippet, node_id: str) -> Response: + def delete(self, current_user: Account, snippet: CustomizedSnippet, node_id: str): validate_node_id(node_id) srv = WorkflowDraftVariableService(db.session()) srv.delete_node_variables(snippet.id, node_id, user_id=current_user.id) db.session.commit() - return Response("", 204) + return "", 204 @console_ns.route("/snippets//workflows/draft/variables/") class SnippetVariableApi(Resource): @console_ns.doc("get_snippet_workflow_variable") @console_ns.doc(description="Get a specific draft workflow variable (snippet scope)") - @console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model) + @console_ns.response( + 200, "Variable retrieved successfully", console_ns.models[WorkflowDraftVariableResponse.__name__] + ) @console_ns.response(404, "Variable not found") @_snippet_draft_var_prerequisite - @marshal_with(workflow_draft_variable_model) - def get(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable: + def get(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> dict[str, Any]: draft_var_srv = WorkflowDraftVariableService(session=db.session()) variable = ensure_variable_access( variable=draft_var_srv.get_variable(variable_id=variable_id), @@ -183,16 +189,17 @@ def get(self, current_user: Account, snippet: CustomizedSnippet, variable_id: st current_user_id=current_user.id, ) _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) - return variable + return WorkflowDraftVariableResponse.from_workflow_draft_variable(variable).model_dump(mode="json") @console_ns.doc("update_snippet_workflow_variable") @console_ns.doc(description="Update a draft workflow variable (snippet scope)") @console_ns.expect(console_ns.models[WorkflowDraftVariableUpdatePayload.__name__]) - @console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model) + @console_ns.response( + 200, "Variable updated successfully", console_ns.models[WorkflowDraftVariableResponse.__name__] + ) @console_ns.response(404, "Variable not found") @_snippet_draft_var_prerequisite - @marshal_with(workflow_draft_variable_model) - def patch(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable: + def patch(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> dict[str, Any]: draft_var_srv = WorkflowDraftVariableService(session=db.session()) args_model = WorkflowDraftVariableUpdatePayload.model_validate(console_ns.payload or {}) @@ -207,39 +214,46 @@ def patch(self, current_user: Account, snippet: CustomizedSnippet, variable_id: new_name = args_model.name raw_value = args_model.value if new_name is None and raw_value is None: - return variable + return WorkflowDraftVariableResponse.from_workflow_draft_variable(variable).model_dump(mode="json") new_value = None if raw_value is not None: - if variable.value_type == SegmentType.FILE: - if not isinstance(raw_value, dict): - raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") - raw_value = build_from_mapping( + new_value_input: Any + match variable.value_type: + case SegmentType.FILE: + if not isinstance(raw_value, dict): + raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") + new_value_input = build_from_mapping( mapping=raw_value, tenant_id=snippet.tenant_id, access_controller=_file_access_controller, ) - elif variable.value_type == SegmentType.ARRAY_FILE: - if not isinstance(raw_value, list): - raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") - if len(raw_value) > 0 and not isinstance(raw_value[0], dict): - raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") - raw_value = build_from_mappings( + case SegmentType.ARRAY_FILE: + if not isinstance(raw_value, list): + raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") + for index, item in enumerate(raw_value): + if not isinstance(item, dict): + raise InvalidArgumentError( + description=f"expected dict for files[{index}], got {type(item)}" + ) + new_value_input = build_from_mappings( mappings=raw_value, tenant_id=snippet.tenant_id, access_controller=_file_access_controller, ) - new_value = build_segment_with_type(variable.value_type, raw_value) + case _: + new_value_input = raw_value + new_value = build_segment_with_type(variable.value_type, new_value_input) draft_var_srv.update_variable(variable, name=new_name, value=new_value) db.session.commit() - return variable + return WorkflowDraftVariableResponse.from_workflow_draft_variable(variable).model_dump(mode="json") @console_ns.doc("delete_snippet_workflow_variable") @console_ns.doc(description="Delete a draft workflow variable (snippet scope)") @console_ns.response(204, "Variable deleted successfully") @console_ns.response(404, "Variable not found") @_snippet_draft_var_prerequisite - def delete(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> Response: + def delete(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str): draft_var_srv = WorkflowDraftVariableService(session=db.session()) variable = ensure_variable_access( variable=draft_var_srv.get_variable(variable_id=variable_id), @@ -250,14 +264,14 @@ def delete(self, current_user: Account, snippet: CustomizedSnippet, variable_id: _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) draft_var_srv.delete_variable(variable) db.session.commit() - return Response("", 204) + return "", 204 @console_ns.route("/snippets//workflows/draft/variables//reset") class SnippetVariableResetApi(Resource): @console_ns.doc("reset_snippet_workflow_variable") @console_ns.doc(description="Reset a draft workflow variable to its default value (snippet scope)") - @console_ns.response(200, "Variable reset successfully", workflow_draft_variable_model) + @console_ns.response(200, "Variable reset successfully", console_ns.models[WorkflowDraftVariableResponse.__name__]) @console_ns.response(204, "Variable reset (no content)") @console_ns.response(404, "Variable not found") @_snippet_draft_var_prerequisite @@ -281,7 +295,7 @@ def put(self, current_user: Account, snippet: CustomizedSnippet, variable_id: st db.session.commit() if resetted is None: return Response("", 204) - return marshal(resetted, workflow_draft_variable_model) + return WorkflowDraftVariableResponse.from_workflow_draft_variable(resetted).model_dump(mode="json") @console_ns.route("/snippets//workflows/draft/conversation-variables") @@ -290,11 +304,16 @@ class SnippetConversationVariableCollectionApi(Resource): @console_ns.doc( description="Conversation variables are not used in snippet workflows; returns an empty list for API parity" ) - @console_ns.response(200, "Conversation variables retrieved successfully", workflow_draft_variable_list_model) + @console_ns.response( + 200, + "Conversation variables retrieved successfully", + console_ns.models[WorkflowDraftVariableListResponse.__name__], + ) @_snippet_draft_var_prerequisite - @marshal_with(workflow_draft_variable_list_model) - def get(self, _current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraftVariableList: - return WorkflowDraftVariableList(variables=[]) + def get(self, _current_user: Account, snippet: CustomizedSnippet) -> dict[str, Any]: + return WorkflowDraftVariableListResponse.from_workflow_draft_variable_list( + WorkflowDraftVariableList(variables=[]) + ).model_dump(mode="json") @console_ns.route("/snippets//workflows/draft/system-variables") @@ -303,11 +322,16 @@ class SnippetSystemVariableCollectionApi(Resource): @console_ns.doc( description="System variables are not used in snippet workflows; returns an empty list for API parity" ) - @console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model) + @console_ns.response( + 200, + "System variables retrieved successfully", + console_ns.models[WorkflowDraftVariableListResponse.__name__], + ) @_snippet_draft_var_prerequisite - @marshal_with(workflow_draft_variable_list_model) - def get(self, _current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraftVariableList: - return WorkflowDraftVariableList(variables=[]) + def get(self, _current_user: Account, snippet: CustomizedSnippet) -> dict[str, Any]: + return WorkflowDraftVariableListResponse.from_workflow_draft_variable_list( + WorkflowDraftVariableList(variables=[]) + ).model_dump(mode="json") @console_ns.route("/snippets//workflows/draft/environment-variables") @@ -317,7 +341,7 @@ class SnippetEnvironmentVariableCollectionApi(Resource): @console_ns.response( 200, "Environment variables retrieved successfully", - console_ns.models[EnvironmentVariableListResponse.__name__], + console_ns.models[WorkflowDraftEnvironmentVariableListResponse.__name__], ) @console_ns.response(404, "Draft workflow not found") @_snippet_draft_var_prerequisite @@ -327,21 +351,21 @@ def get(self, _current_user: Account, snippet: CustomizedSnippet) -> dict[str, l if workflow is None: raise DraftWorkflowNotExist() - env_vars_list: list[dict[str, Any]] = [] + env_vars_list: list[WorkflowDraftEnvironmentVariableResponse] = [] for v in workflow.environment_variables: env_vars_list.append( - { - "id": v.id, - "type": "env", - "name": v.name, - "description": v.description, - "selector": v.selector, - "value_type": v.value_type.exposed_type().value, - "value": v.value, - "edited": False, - "visible": True, - "editable": True, - } + WorkflowDraftEnvironmentVariableResponse( + id=v.id, + type="env", + name=v.name, + description=v.description, + selector=list(v.selector), + value_type=v.value_type.exposed_type().value, + value=v.value, + edited=False, + visible=True, + editable=True, + ) ) - return {"items": env_vars_list} + return WorkflowDraftEnvironmentVariableListResponse(items=env_vars_list).model_dump(mode="json") diff --git a/api/controllers/console/spec.py b/api/controllers/console/spec.py index 27b07b4dd81779..70e0d1d14ae01b 100644 --- a/api/controllers/console/spec.py +++ b/api/controllers/console/spec.py @@ -1,8 +1,9 @@ import logging +from collections.abc import Mapping from typing import Any from flask_restx import Resource -from pydantic import RootModel +from pydantic import Field, RootModel from controllers.common.schema import register_response_schema_models from controllers.console.wraps import ( @@ -10,6 +11,7 @@ setup_required, ) from core.schemas.schema_manager import SchemaManager +from fields.base import ResponseModel from libs.login import login_required from . import console_ns @@ -17,11 +19,17 @@ logger = logging.getLogger(__name__) -class SchemaDefinitionsResponse(RootModel[Any]): - root: Any +class SchemaDefinitionItemResponse(ResponseModel): + name: str + label: str + schema_: Mapping[str, Any] = Field(alias="schema") -register_response_schema_models(console_ns, SchemaDefinitionsResponse) +class SchemaDefinitionsResponse(RootModel[list[SchemaDefinitionItemResponse]]): + pass + + +register_response_schema_models(console_ns, SchemaDefinitionItemResponse, SchemaDefinitionsResponse) @console_ns.route("/spec/schema-definitions") diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index 38e7395ccf8f06..90fa805e939191 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -3,7 +3,7 @@ from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, RootModel, field_validator from werkzeug.exceptions import Forbidden from controllers.common.fields import SimpleResultResponse @@ -18,6 +18,7 @@ ) from extensions.ext_database import db from fields.base import ResponseModel +from libs.helper import dump_response from libs.login import login_required from models import Account from models.enums import TagType @@ -79,6 +80,10 @@ def normalize_binding_count(cls, value: int | str | None) -> str | None: return str(value) +class TagListResponse(RootModel[list[TagResponse]]): + pass + + register_schema_models( console_ns, TagBasePayload, @@ -86,9 +91,8 @@ def normalize_binding_count(cls, value: int | str | None) -> str | None: TagBindingPayload, TagBindingRemovePayload, TagListQueryParam, - TagResponse, ) -register_response_schema_models(console_ns, SimpleResultResponse) +register_response_schema_models(console_ns, SimpleResultResponse, TagResponse, TagListResponse) @console_ns.route("/tags") @@ -97,18 +101,14 @@ class TagListApi(Resource): @login_required @account_initialization_required @console_ns.doc(params=query_params_from_model(TagListQueryParam)) - @console_ns.doc(responses={200: ("Success", [console_ns.models[TagResponse.__name__]])}) + @console_ns.response(200, "Success", console_ns.models[TagListResponse.__name__]) @with_current_tenant_id def get(self, current_tenant_id: str): raw_args = request.args.to_dict() param = TagListQueryParam.model_validate(raw_args) tags = TagService.get_tags(db.session(), param.type, current_tenant_id, param.keyword) - serialized_tags = [ - TagResponse.model_validate(tag, from_attributes=True).model_dump(mode="json") for tag in tags - ] - - return serialized_tags, 200 + return dump_response(TagListResponse, tags), 200 @console_ns.expect(console_ns.models[TagBasePayload.__name__]) @console_ns.response(200, "Success", console_ns.models[TagResponse.__name__]) @@ -124,11 +124,7 @@ def post(self, current_user: Account): payload = TagBasePayload.model_validate(console_ns.payload or {}) tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type), db.session) - response = TagResponse.model_validate( - {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0} - ).model_dump(mode="json") - - return response, 200 + return dump_response(TagResponse, {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}), 200 @console_ns.route("/tags/") @@ -150,11 +146,13 @@ def patch(self, current_user: Account, tag_id: UUID): binding_count = TagService.get_tag_binding_count(tag_id_str, db.session) - response = TagResponse.model_validate( - {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count} - ).model_dump(mode="json") - - return response, 200 + return ( + dump_response( + TagResponse, + {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count}, + ), + 200, + ) @setup_required @login_required @@ -180,7 +178,7 @@ def _require_tag_binding_edit_permission(current_user: Account) -> None: raise Forbidden() -def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]: +def _create_tag_bindings(current_user: Account) -> None: _require_tag_binding_edit_permission(current_user) payload = TagBindingPayload.model_validate(console_ns.payload or {}) @@ -192,10 +190,9 @@ def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]: ), db.session, ) - return {"result": "success"}, 200 -def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]: +def _remove_tag_bindings(current_user: Account) -> None: _require_tag_binding_edit_permission(current_user) payload = TagBindingRemovePayload.model_validate(console_ns.payload or {}) @@ -207,7 +204,6 @@ def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]: ), db.session, ) - return {"result": "success"}, 200 @console_ns.route("/tag-bindings") @@ -222,7 +218,8 @@ class TagBindingCollectionApi(Resource): @account_initialization_required @with_current_user def post(self, current_user: Account): - return _create_tag_bindings(current_user) + _create_tag_bindings(current_user) + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.route("/tag-bindings/remove") @@ -238,4 +235,5 @@ class TagBindingRemoveApi(Resource): @account_initialization_required @with_current_user def post(self, current_user: Account): - return _remove_tag_bindings(current_user) + _remove_tag_bindings(current_user) + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index c13c8aa162f906..e2aae8eee5e80a 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -1,12 +1,12 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Literal +from typing import Literal import pytz from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field, RootModel, field_validator, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator from sqlalchemy import select from werkzeug.exceptions import NotFound @@ -47,7 +47,7 @@ ) from extensions.ext_database import db from fields.base import ResponseModel -from fields.member_fields import Account as AccountResponse +from fields.member_fields import AccountResponse from graphon.file import helpers as file_helpers from libs.datetime_utils import naive_utc_now from libs.helper import EmailStr, dump_response, extract_remote_ip, timezone, to_timestamp @@ -194,10 +194,6 @@ class CheckEmailUniquePayload(BaseModel): ) -def _serialize_account(account) -> dict[str, Any]: - return AccountResponse.model_validate(account, from_attributes=True).model_dump(mode="json") - - class AccountIntegrateResponse(ResponseModel): provider: str created_at: int | None = None @@ -236,23 +232,15 @@ class EducationAutocompleteResponse(ResponseModel): has_next: bool | None = None -class EducationActivateResponse(RootModel[dict[str, Any]]): - root: dict[str, Any] - - -register_schema_models( +register_response_schema_models( console_ns, + AccountResponse, AccountIntegrateResponse, AccountIntegrateListResponse, + AvatarUrlResponse, EducationVerifyResponse, EducationStatusResponse, EducationAutocompleteResponse, -) -register_response_schema_models( - console_ns, - AccountResponse, - AvatarUrlResponse, - EducationActivateResponse, SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse, @@ -302,7 +290,7 @@ def post(self, account: Account): account.initialized_at = naive_utc_now() db.session.commit() - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/account/profile") @@ -314,7 +302,7 @@ class AccountProfileApi(Resource): @enterprise_license_required @with_current_user def get(self, current_user: Account): - return _serialize_account(current_user) + return dump_response(AccountResponse, current_user) @console_ns.route("/account/name") @@ -330,7 +318,7 @@ def post(self, current_user: Account): args = AccountNamePayload.model_validate(payload) updated_account = AccountService.update_account(current_user, session=db.session, name=args.name) - return _serialize_account(updated_account) + return dump_response(AccountResponse, updated_account) @console_ns.route("/account/avatar") @@ -349,7 +337,7 @@ def get(self, current_tenant_id: str, current_user: Account): avatar = args.avatar if avatar.startswith(("http://", "https://")): - return dump_response(AvatarUrlResponse, {"avatar_url": avatar}) + return AvatarUrlResponse(avatar_url=avatar).model_dump(mode="json") upload_file = db.session.scalar(select(UploadFile).where(UploadFile.id == avatar).limit(1)) if upload_file is None: @@ -362,7 +350,7 @@ def get(self, current_tenant_id: str, current_user: Account): raise NotFound("Avatar file not found") avatar_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id) - return dump_response(AvatarUrlResponse, {"avatar_url": avatar_url}) + return AvatarUrlResponse(avatar_url=avatar_url).model_dump(mode="json") @console_ns.expect(console_ns.models[AccountAvatarPayload.__name__]) @setup_required @@ -376,7 +364,7 @@ def post(self, current_user: Account): updated_account = AccountService.update_account(current_user, session=db.session, avatar=args.avatar) - return _serialize_account(updated_account) + return dump_response(AccountResponse, updated_account) @console_ns.route("/account/interface-language") @@ -395,7 +383,7 @@ def post(self, current_user: Account): current_user, session=db.session, interface_language=args.interface_language ) - return _serialize_account(updated_account) + return dump_response(AccountResponse, updated_account) @console_ns.route("/account/interface-theme") @@ -414,7 +402,7 @@ def post(self, current_user: Account): current_user, session=db.session, interface_theme=args.interface_theme ) - return _serialize_account(updated_account) + return dump_response(AccountResponse, updated_account) @console_ns.route("/account/timezone") @@ -431,7 +419,7 @@ def post(self, current_user: Account): updated_account = AccountService.update_account(current_user, session=db.session, timezone=args.timezone) - return _serialize_account(updated_account) + return dump_response(AccountResponse, updated_account) @console_ns.route("/account/password") @@ -452,7 +440,7 @@ def post(self, current_user: Account): except ServiceCurrentPasswordIncorrectError: raise CurrentPasswordIncorrectError() - return _serialize_account(current_user) + return dump_response(AccountResponse, current_user) @console_ns.route("/account/integrates") @@ -471,33 +459,29 @@ def get(self, account: Account): oauth_base_path = "/console/api/oauth/login" providers = ["github", "google"] - integrate_data = [] + integrate_data: list[AccountIntegrateResponse] = [] for provider in providers: existing_integrate = next((ai for ai in account_integrates if ai.provider == provider), None) if existing_integrate: integrate_data.append( - { - "id": existing_integrate.id, - "provider": provider, - "created_at": existing_integrate.created_at, - "is_bound": True, - "link": None, - } + AccountIntegrateResponse( + provider=provider, + created_at=to_timestamp(existing_integrate.created_at), + is_bound=True, + link=None, + ) ) else: integrate_data.append( - { - "id": None, - "provider": provider, - "created_at": None, - "is_bound": False, - "link": f"{base_url}{oauth_base_path}/{provider}", - } + AccountIntegrateResponse( + provider=provider, + created_at=None, + is_bound=False, + link=f"{base_url}{oauth_base_path}/{provider}", + ) ) - return AccountIntegrateListResponse( - data=[AccountIntegrateResponse.model_validate(item) for item in integrate_data] - ).model_dump(mode="json") + return AccountIntegrateListResponse(data=integrate_data).model_dump(mode="json") @console_ns.route("/account/delete/verify") @@ -511,7 +495,7 @@ def get(self, account: Account): token, code = AccountService.generate_account_deletion_verification_code(account) AccountService.send_account_deletion_verification_email(account, code) - return {"result": "success", "data": token} + return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json") @console_ns.route("/account/delete") @@ -531,7 +515,7 @@ def post(self, account: Account): AccountService.delete_account(account) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/account/delete/feedback") @@ -545,7 +529,7 @@ def post(self): BillingService.update_account_deletion_feedback(args.email, args.feedback) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/account/education/verify") @@ -558,15 +542,16 @@ class EducationVerifyApi(Resource): @console_ns.response(200, "Success", console_ns.models[EducationVerifyResponse.__name__]) @with_current_user def get(self, account: Account): - return EducationVerifyResponse.model_validate( - BillingService.EducationIdentity.verify(account.id, account.email) or {} - ).model_dump(mode="json") + return dump_response( + EducationVerifyResponse, BillingService.EducationIdentity.verify(account.id, account.email) or {} + ) @console_ns.route("/account/education") class EducationApi(Resource): @console_ns.expect(console_ns.models[EducationActivatePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[EducationActivateResponse.__name__]) + # response-contract:ignore billing-service activation payload; TODO: model education activation result. + @console_ns.response(200, "Success") @setup_required @login_required @account_initialization_required @@ -577,7 +562,8 @@ def post(self, account: Account): payload = console_ns.payload or {} args = EducationActivatePayload.model_validate(payload) - return BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role) + result = BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role) + return result @setup_required @login_required @@ -591,7 +577,7 @@ def get(self, account: Account): # convert expire_at to UTC timestamp from isoformat if res and "expire_at" in res: res["expire_at"] = datetime.fromisoformat(res["expire_at"]).astimezone(pytz.utc) - return EducationStatusResponse.model_validate(res).model_dump(mode="json") + return dump_response(EducationStatusResponse, res) @console_ns.route("/account/education/autocomplete") @@ -607,9 +593,10 @@ def get(self): payload = request.args.to_dict(flat=True) args = EducationAutocompleteQuery.model_validate(payload) - return EducationAutocompleteResponse.model_validate( - BillingService.EducationIdentity.autocomplete(args.keywords, args.page, args.limit) or {} - ).model_dump(mode="json") + return dump_response( + EducationAutocompleteResponse, + BillingService.EducationIdentity.autocomplete(args.keywords, args.page, args.limit) or {}, + ) @console_ns.route("/account/change-email") @@ -669,7 +656,7 @@ def post(self, current_user: Account): language=language, phase=send_phase, ) - return {"result": "success", "data": token} + return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json") @console_ns.route("/account/change-email/validity") @@ -716,7 +703,9 @@ def post(self, current_user: Account): new_token = AccountService.generate_change_email_token(refreshed_token_data, current_user) AccountService.reset_change_email_error_rate_limit(user_email) - return {"is_valid": True, "email": normalized_token_email, "token": new_token} + return VerificationTokenResponse(is_valid=True, email=normalized_token_email, token=new_token).model_dump( + mode="json" + ) @console_ns.route("/account/change-email/reset") @@ -768,7 +757,7 @@ def post(self, current_user: Account): email=normalized_new_email, ) - return _serialize_account(updated_account) + return dump_response(AccountResponse, updated_account) @console_ns.route("/account/change-email/check-email-unique") @@ -784,4 +773,4 @@ def post(self): raise AccountInFreezeError() if not AccountService.check_email_unique(normalized_email, session=db.session): raise EmailAlreadyInUseError() - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") diff --git a/api/controllers/console/workspace/endpoint.py b/api/controllers/console/workspace/endpoint.py index ddb0f7045d905f..c55186ab984e45 100644 --- a/api/controllers/console/workspace/endpoint.py +++ b/api/controllers/console/workspace/endpoint.py @@ -12,7 +12,8 @@ from flask_restx import Resource from pydantic import BaseModel, Field -from controllers.common.schema import query_params_from_model, register_schema_models +from controllers.common.fields import SuccessResponse +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( RBACPermission, @@ -24,8 +25,9 @@ with_current_tenant_id, with_current_user_id, ) +from core.plugin.entities.endpoint import EndpointEntityWithInstance from core.plugin.impl.exc import PluginPermissionDeniedError -from graphon.model_runtime.utils.encoders import jsonable_encoder +from fields.base import ResponseModel from libs.login import login_required from services.plugin.endpoint_service import EndpointService @@ -40,14 +42,17 @@ class EndpointIdPayload(BaseModel): endpoint_id: str -class EndpointUpdatePayload(BaseModel): +class EndpointSettingsPayload(BaseModel): settings: dict[str, Any] name: str = Field(min_length=1) -class LegacyEndpointUpdatePayload(EndpointIdPayload): - settings: dict[str, Any] - name: str = Field(min_length=1) +class EndpointUpdatePayload(EndpointSettingsPayload): + pass + + +class LegacyEndpointUpdatePayload(EndpointIdPayload, EndpointSettingsPayload): + pass class EndpointListQuery(BaseModel): @@ -59,98 +64,85 @@ class EndpointListForPluginQuery(EndpointListQuery): plugin_id: str -class EndpointCreateResponse(BaseModel): - success: bool = Field(description="Operation success") - - -class EndpointListResponse(BaseModel): - endpoints: list[dict[str, Any]] = Field( - description="Endpoint information", - ) - - -class PluginEndpointListResponse(BaseModel): - endpoints: list[dict[str, Any]] = Field( - description="Endpoint information", - ) - - -class EndpointDeleteResponse(BaseModel): - success: bool = Field(description="Operation success") - - -class EndpointUpdateResponse(BaseModel): - success: bool = Field(description="Operation success") - - -class EndpointEnableResponse(BaseModel): - success: bool = Field(description="Operation success") - - -class EndpointDisableResponse(BaseModel): - success: bool = Field(description="Operation success") +class EndpointListResponse(ResponseModel): + endpoints: list[EndpointEntityWithInstance] = Field(description="Endpoint information") register_schema_models( console_ns, EndpointCreatePayload, EndpointIdPayload, + EndpointSettingsPayload, EndpointUpdatePayload, LegacyEndpointUpdatePayload, EndpointListQuery, EndpointListForPluginQuery, - EndpointCreateResponse, +) +register_response_schema_models( + console_ns, + SuccessResponse, EndpointListResponse, - PluginEndpointListResponse, - EndpointDeleteResponse, - EndpointUpdateResponse, - EndpointEnableResponse, - EndpointDisableResponse, ) -def _create_endpoint(tenant_id: str, user_id: str) -> dict[str, bool]: +def _create_endpoint(tenant_id: str, user_id: str) -> bool: """Create a plugin endpoint for the injected workspace and user.""" args = EndpointCreatePayload.model_validate(console_ns.payload) try: - return { - "success": EndpointService.create_endpoint( - tenant_id=tenant_id, - user_id=user_id, - plugin_unique_identifier=args.plugin_unique_identifier, - name=args.name, - settings=args.settings, - ) - } + return EndpointService.create_endpoint( + tenant_id=tenant_id, + user_id=user_id, + plugin_unique_identifier=args.plugin_unique_identifier, + name=args.name, + settings=args.settings, + ) except PluginPermissionDeniedError as e: raise ValueError(e.description) from e -def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> dict[str, bool]: +def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool: """Update a plugin endpoint identified by the canonical path parameter.""" args = EndpointUpdatePayload.model_validate(console_ns.payload) - return { - "success": EndpointService.update_endpoint( - tenant_id=tenant_id, - user_id=user_id, - endpoint_id=endpoint_id, - name=args.name, - settings=args.settings, - ) - } + return EndpointService.update_endpoint( + tenant_id=tenant_id, + user_id=user_id, + endpoint_id=endpoint_id, + name=args.name, + settings=args.settings, + ) + + +def _legacy_update_endpoint(tenant_id: str, user_id: str) -> bool: + args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload) + return EndpointService.update_endpoint( + tenant_id=tenant_id, + user_id=user_id, + endpoint_id=args.endpoint_id, + name=args.name, + settings=args.settings, + ) -def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> dict[str, bool]: +def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool: """Delete a plugin endpoint identified by the canonical path parameter.""" - return { - "success": EndpointService.delete_endpoint( - tenant_id=tenant_id, - user_id=user_id, - endpoint_id=endpoint_id, - ) - } + return EndpointService.delete_endpoint( + tenant_id=tenant_id, + user_id=user_id, + endpoint_id=endpoint_id, + ) + + +def _delete_endpoint_from_payload(tenant_id: str, user_id: str) -> bool: + args = EndpointIdPayload.model_validate(console_ns.payload) + return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id) + + +def _set_endpoint_enabled(tenant_id: str, user_id: str, *, enabled: bool) -> bool: + args = EndpointIdPayload.model_validate(console_ns.payload) + action = EndpointService.enable_endpoint if enabled else EndpointService.disable_endpoint + return action(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id) @console_ns.route("/workspaces/current/endpoints") @@ -163,7 +155,7 @@ class EndpointCollectionApi(Resource): @console_ns.response( 200, "Endpoint created successfully", - console_ns.models[EndpointCreateResponse.__name__], + console_ns.models[SuccessResponse.__name__], ) @console_ns.response(403, "Admin privileges required") @setup_required @@ -174,7 +166,7 @@ class EndpointCollectionApi(Resource): @with_current_user_id @with_current_tenant_id def post(self, tenant_id: str, user_id: str): - return _create_endpoint(tenant_id=tenant_id, user_id=user_id) + return SuccessResponse(success=_create_endpoint(tenant_id=tenant_id, user_id=user_id)).model_dump(mode="json") @console_ns.route("/workspaces/current/endpoints/create") @@ -192,7 +184,7 @@ class DeprecatedEndpointCreateApi(Resource): @console_ns.response( 200, "Endpoint created successfully", - console_ns.models[EndpointCreateResponse.__name__], + console_ns.models[SuccessResponse.__name__], ) @console_ns.response(403, "Admin privileges required") @setup_required @@ -203,7 +195,7 @@ class DeprecatedEndpointCreateApi(Resource): @with_current_user_id @with_current_tenant_id def post(self, tenant_id: str, user_id: str): - return _create_endpoint(tenant_id=tenant_id, user_id=user_id) + return SuccessResponse(success=_create_endpoint(tenant_id=tenant_id, user_id=user_id)).model_dump(mode="json") @console_ns.route("/workspaces/current/endpoints/list") @@ -224,19 +216,14 @@ class EndpointListApi(Resource): def get(self, tenant_id: str, user_id: str): args = EndpointListQuery.model_validate(request.args.to_dict(flat=True)) - page = args.page - page_size = args.page_size - - return jsonable_encoder( - { - "endpoints": EndpointService.list_endpoints( - tenant_id=tenant_id, - user_id=user_id, - page=page, - page_size=page_size, - ) - } - ) + return EndpointListResponse( + endpoints=EndpointService.list_endpoints( + tenant_id=tenant_id, + user_id=user_id, + page=args.page, + page_size=args.page_size, + ) + ).model_dump(mode="json") @console_ns.route("/workspaces/current/endpoints/list/plugin") @@ -247,7 +234,7 @@ class EndpointListForSinglePluginApi(Resource): @console_ns.response( 200, "Success", - console_ns.models[PluginEndpointListResponse.__name__], + console_ns.models[EndpointListResponse.__name__], ) @setup_required @login_required @@ -257,21 +244,15 @@ class EndpointListForSinglePluginApi(Resource): def get(self, tenant_id: str, user_id: str): args = EndpointListForPluginQuery.model_validate(request.args.to_dict(flat=True)) - page = args.page - page_size = args.page_size - plugin_id = args.plugin_id - - return jsonable_encoder( - { - "endpoints": EndpointService.list_endpoints_for_single_plugin( - tenant_id=tenant_id, - user_id=user_id, - plugin_id=plugin_id, - page=page, - page_size=page_size, - ) - } - ) + return EndpointListResponse( + endpoints=EndpointService.list_endpoints_for_single_plugin( + tenant_id=tenant_id, + user_id=user_id, + plugin_id=args.plugin_id, + page=args.page, + page_size=args.page_size, + ) + ).model_dump(mode="json") @console_ns.route("/workspaces/current/endpoints/") @@ -284,7 +265,7 @@ class EndpointItemApi(Resource): @console_ns.response( 200, "Endpoint deleted successfully", - console_ns.models[EndpointDeleteResponse.__name__], + console_ns.models[SuccessResponse.__name__], ) @console_ns.response(403, "Admin privileges required") @setup_required @@ -295,7 +276,9 @@ class EndpointItemApi(Resource): @with_current_user_id @with_current_tenant_id def delete(self, tenant_id: str, user_id: str, id: str): - return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id) + return SuccessResponse( + success=_delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id) + ).model_dump(mode="json") @console_ns.doc("update_endpoint") @console_ns.doc(description="Update a plugin endpoint") @@ -304,7 +287,7 @@ def delete(self, tenant_id: str, user_id: str, id: str): @console_ns.response( 200, "Endpoint updated successfully", - console_ns.models[EndpointUpdateResponse.__name__], + console_ns.models[SuccessResponse.__name__], ) @console_ns.response(403, "Admin privileges required") @setup_required @@ -315,7 +298,9 @@ def delete(self, tenant_id: str, user_id: str, id: str): @with_current_user_id @with_current_tenant_id def patch(self, tenant_id: str, user_id: str, id: str): - return _update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id) + return SuccessResponse( + success=_update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id) + ).model_dump(mode="json") @console_ns.route("/workspaces/current/endpoints/delete") @@ -334,7 +319,7 @@ class DeprecatedEndpointDeleteApi(Resource): @console_ns.response( 200, "Endpoint deleted successfully", - console_ns.models[EndpointDeleteResponse.__name__], + console_ns.models[SuccessResponse.__name__], ) @console_ns.response(403, "Admin privileges required") @setup_required @@ -345,8 +330,9 @@ class DeprecatedEndpointDeleteApi(Resource): @with_current_user_id @with_current_tenant_id def post(self, tenant_id: str, user_id: str): - args = EndpointIdPayload.model_validate(console_ns.payload) - return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id) + return SuccessResponse( # + success=_delete_endpoint_from_payload(tenant_id=tenant_id, user_id=user_id) + ).model_dump(mode="json") @console_ns.route("/workspaces/current/endpoints/update") @@ -365,7 +351,7 @@ class DeprecatedEndpointUpdateApi(Resource): @console_ns.response( 200, "Endpoint updated successfully", - console_ns.models[EndpointUpdateResponse.__name__], + console_ns.models[SuccessResponse.__name__], ) @console_ns.response(403, "Admin privileges required") @setup_required @@ -376,8 +362,9 @@ class DeprecatedEndpointUpdateApi(Resource): @with_current_user_id @with_current_tenant_id def post(self, tenant_id: str, user_id: str): - args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload) - return _update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id) + return SuccessResponse( # + success=_legacy_update_endpoint(tenant_id=tenant_id, user_id=user_id) + ).model_dump(mode="json") @console_ns.route("/workspaces/current/endpoints/enable") @@ -388,7 +375,7 @@ class EndpointEnableApi(Resource): @console_ns.response( 200, "Endpoint enabled successfully", - console_ns.models[EndpointEnableResponse.__name__], + console_ns.models[SuccessResponse.__name__], ) @console_ns.response(403, "Admin privileges required") @setup_required @@ -399,13 +386,9 @@ class EndpointEnableApi(Resource): @with_current_user_id @with_current_tenant_id def post(self, tenant_id: str, user_id: str): - args = EndpointIdPayload.model_validate(console_ns.payload) - - return { - "success": EndpointService.enable_endpoint( - tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id - ) - } + return SuccessResponse( + success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, enabled=True) + ).model_dump(mode="json") @console_ns.route("/workspaces/current/endpoints/disable") @@ -416,7 +399,7 @@ class EndpointDisableApi(Resource): @console_ns.response( 200, "Endpoint disabled successfully", - console_ns.models[EndpointDisableResponse.__name__], + console_ns.models[SuccessResponse.__name__], ) @console_ns.response(403, "Admin privileges required") @setup_required @@ -427,10 +410,6 @@ class EndpointDisableApi(Resource): @with_current_user_id @with_current_tenant_id def post(self, tenant_id: str, user_id: str): - args = EndpointIdPayload.model_validate(console_ns.payload) - - return { - "success": EndpointService.disable_endpoint( - tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id - ) - } + return SuccessResponse( + success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, enabled=False) + ).model_dump(mode="json") diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 3a2e3c923599c9..cb25180fb033c6 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -3,7 +3,7 @@ from flask import abort, request from flask_restx import Resource -from pydantic import BaseModel, Field, TypeAdapter +from pydantic import BaseModel, Field from sqlalchemy import func, select import services @@ -30,8 +30,8 @@ from extensions.ext_database import db from extensions.ext_redis import redis_client from fields.base import ResponseModel -from fields.member_fields import AccountWithRole, AccountWithRoleList -from libs.helper import extract_remote_ip +from fields.member_fields import AccountWithRoleListResponse, AccountWithRoleResponse +from libs.helper import dump_response, extract_remote_ip from libs.login import current_account_with_tenant, login_required from models.account import Account, TenantAccountJoin, TenantAccountRole from services.account_service import AccountService, RegisterService, TenantService @@ -70,22 +70,20 @@ class MemberInviteResultResponse(ResponseModel): message: str | None = None -class MemberInviteResponse(ResponseModel): +class MemberActionResponse(ResponseModel): result: str - invitation_results: list[MemberInviteResultResponse] - tenant_id: str + tenant_id: str = "" -class MemberActionTenantResponse(ResponseModel): +class MemberInviteResponse(ResponseModel): result: str + invitation_results: list[MemberInviteResultResponse] tenant_id: str register_enum_models(console_ns, TenantAccountRole) register_schema_models( console_ns, - AccountWithRole, - AccountWithRoleList, MemberInvitePayload, MemberRoleUpdatePayload, OwnerTransferEmailPayload, @@ -94,11 +92,14 @@ class MemberActionTenantResponse(ResponseModel): ) register_response_schema_models( console_ns, + AccountWithRoleResponse, + AccountWithRoleListResponse, + MemberActionResponse, + MemberInviteResponse, + MemberInviteResultResponse, SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse, - MemberInviteResponse, - MemberActionTenantResponse, ) @@ -179,7 +180,7 @@ class MemberListApi(Resource): @setup_required @login_required @account_initialization_required - @console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__]) + @console_ns.response(200, "Success", console_ns.models[AccountWithRoleListResponse.__name__]) @with_current_user def get(self, current_user: Account | None = None): if current_user is None: @@ -216,9 +217,7 @@ def get(self, current_user: Account | None = None): } ) - member_models = TypeAdapter(list[AccountWithRole]).validate_python(serialized_members) - response = AccountWithRoleList(accounts=member_models) - return response.model_dump(mode="json"), 200 + return dump_response(AccountWithRoleListResponse, {"accounts": serialized_members}), 200 @console_ns.route("/workspaces/current/members/invite-email") @@ -254,7 +253,7 @@ def post(self, current_user: Account): check_workspace_member_invite_permission(inviter.current_tenant.id) - invitation_results = [] + invitation_results: list[MemberInviteResultResponse] = [] console_web_url = dify_config.CONSOLE_WEB_URL tenant_id = inviter.current_tenant.id @@ -277,38 +276,40 @@ def post(self, current_user: Account): ) encoded_invitee_email = parse.quote(invitee_email) invitation_results.append( - { - "status": "success", - "email": invitee_email, - "url": f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}", - } + MemberInviteResultResponse( + status="success", + email=invitee_email, + url=f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}", + ) ) except AccountAlreadyInTenantError: invitation_results.append( - { - "status": "already_member", - "email": invitee_email, - "message": "Account already in workspace.", - } + MemberInviteResultResponse( + status="already_member", + email=invitee_email, + message="Account already in workspace.", + ) ) except Exception as e: - invitation_results.append({"status": "failed", "email": invitee_email, "message": str(e)}) + invitation_results.append( + MemberInviteResultResponse(status="failed", email=invitee_email, message=str(e)) + ) - return { - "result": "success", - "invitation_results": invitation_results, - "tenant_id": str(inviter.current_tenant.id) if inviter.current_tenant else "", - }, 201 + return MemberInviteResponse( + result="success", + invitation_results=invitation_results, + tenant_id=inviter.current_tenant.id if inviter.current_tenant else "", + ).model_dump(mode="json"), 201 @console_ns.route("/workspaces/current/members/") class MemberCancelInviteApi(Resource): """Cancel an invitation by member id.""" - @console_ns.response(200, "Success", console_ns.models[MemberActionTenantResponse.__name__]) @setup_required @login_required @account_initialization_required + @console_ns.response(200, "Success", console_ns.models[MemberActionResponse.__name__]) @with_current_user def delete(self, current_user: Account, member_id: UUID): if not current_user.current_tenant: @@ -330,10 +331,10 @@ def delete(self, current_user: Account, member_id: UUID): except Exception as e: raise ValueError(str(e)) - return { - "result": "success", - "tenant_id": str(current_user.current_tenant.id) if current_user.current_tenant else "", - }, 200 + return MemberActionResponse( + result="success", + tenant_id=current_user.current_tenant.id if current_user.current_tenant else "", + ).model_dump(mode="json"), 200 @console_ns.route("/workspaces/current/members//update-role") @@ -377,7 +378,7 @@ def put(self, current_user: Account, member_id: UUID): except Exception as e: raise ValueError(str(e)) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/workspaces/current/dataset-operators") @@ -387,15 +388,13 @@ class DatasetOperatorMemberListApi(Resource): @setup_required @login_required @account_initialization_required - @console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__]) + @console_ns.response(200, "Success", console_ns.models[AccountWithRoleListResponse.__name__]) @with_current_user def get(self, current_user: Account): if not current_user.current_tenant: raise ValueError("No current tenant") members = TenantService.get_dataset_operator_members(current_user.current_tenant, session=db.session) - member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True) - response = AccountWithRoleList(accounts=member_models) - return response.model_dump(mode="json"), 200 + return dump_response(AccountWithRoleListResponse, {"accounts": members}), 200 @console_ns.route("/workspaces/current/members/send-owner-transfer-confirm-email") @@ -435,7 +434,7 @@ def post(self, current_user: Account): workspace_name=current_user.current_tenant.name if current_user.current_tenant else "", ) - return {"result": "success", "data": token} + return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json") @console_ns.route("/workspaces/current/members/owner-transfer-check") @@ -480,7 +479,7 @@ def post(self, current_user: Account): _, new_token = AccountService.generate_owner_transfer_token(user_email, code=args.code, additional_data={}) AccountService.reset_owner_transfer_error_rate_limit(user_email) - return {"is_valid": True, "email": token_data.get("email"), "token": new_token} + return VerificationTokenResponse(is_valid=True, email=user_email, token=new_token).model_dump(mode="json") @console_ns.route("/workspaces/current/members//owner-transfer") @@ -546,4 +545,4 @@ def post(self, current_user: Account, member_id: UUID): except Exception as e: raise ValueError(str(e)) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") diff --git a/api/controllers/console/workspace/model_providers.py b/api/controllers/console/workspace/model_providers.py index 3ce7211703eb9b..761c6f032694fd 100644 --- a/api/controllers/console/workspace/model_providers.py +++ b/api/controllers/console/workspace/model_providers.py @@ -5,7 +5,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field, field_validator -from controllers.common.fields import BinaryFileResponse, SimpleResultResponse +from controllers.common.fields import SimpleResultResponse, ValidationResultResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( @@ -21,8 +21,7 @@ from fields.base import ResponseModel from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.errors.validate import CredentialsValidateFailedError -from graphon.model_runtime.utils.encoders import jsonable_encoder -from libs.helper import uuid_value +from libs.helper import dump_response, uuid_value from libs.login import login_required from models import Account from services.billing_service import BillingService @@ -91,13 +90,8 @@ class ModelProviderListResponse(ResponseModel): data: list[ProviderResponse] -class ProviderCredentialResponse(ResponseModel): - credentials: dict[str, Any] | None = Field(default=None) - - -class ProviderCredentialValidateResponse(ResponseModel): - result: Literal["success", "error"] - error: str | None = None +class ProviderCredentialsResponse(ResponseModel): + credentials: dict[str, Any] | None = None class ModelProviderPaymentCheckoutUrlResponse(ResponseModel): @@ -117,19 +111,20 @@ class ModelProviderPaymentCheckoutUrlResponse(ResponseModel): ) register_response_schema_models( console_ns, - BinaryFileResponse, SimpleResultResponse, ModelProviderListResponse, + ProviderCredentialsResponse, + ValidationResultResponse, ModelProviderPaymentCheckoutUrlResponse, - ProviderCredentialResponse, - ProviderCredentialValidateResponse, ) @console_ns.route("/workspaces/current/model-providers") class ModelProviderListApi(Resource): @console_ns.doc(params=query_params_from_model(ParserModelList)) - @console_ns.response(200, "Success", console_ns.models[ModelProviderListResponse.__name__]) + @console_ns.response( + 200, "Model providers retrieved successfully", console_ns.models[ModelProviderListResponse.__name__] + ) @setup_required @login_required @account_initialization_required @@ -141,13 +136,17 @@ def get(self, tenant_id: str): model_provider_service = ModelProviderService() provider_list = model_provider_service.get_provider_list(tenant_id=tenant_id, model_type=args.model_type) - return jsonable_encoder({"data": provider_list}) + return ModelProviderListResponse(data=provider_list).model_dump(mode="json") @console_ns.route("/workspaces/current/model-providers//credentials") class ModelProviderCredentialApi(Resource): @console_ns.doc(params=query_params_from_model(ParserCredentialId)) - @console_ns.response(200, "Success", console_ns.models[ProviderCredentialResponse.__name__]) + @console_ns.response( + 200, + "Provider credentials retrieved successfully", + console_ns.models[ProviderCredentialsResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -162,7 +161,7 @@ def get(self, tenant_id: str, provider: str): tenant_id=tenant_id, provider=provider, credential_id=args.credential_id ) - return {"credentials": credentials} + return ProviderCredentialsResponse(credentials=credentials).model_dump(mode="json") @console_ns.expect(console_ns.models[ParserCredentialCreate.__name__]) @console_ns.response(201, "Credential created successfully", console_ns.models[SimpleResultResponse.__name__]) @@ -188,7 +187,7 @@ def post(self, current_tenant_id: str, provider: str): except CredentialsValidateFailedError as ex: raise ValueError(str(ex)) - return {"result": "success"}, 201 + return SimpleResultResponse(result="success").model_dump(mode="json"), 201 @console_ns.expect(console_ns.models[ParserCredentialUpdate.__name__]) @console_ns.response(200, "Credential updated successfully", console_ns.models[SimpleResultResponse.__name__]) @@ -215,7 +214,7 @@ def put(self, current_tenant_id: str, provider: str): except CredentialsValidateFailedError as ex: raise ValueError(str(ex)) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.expect(console_ns.models[ParserCredentialDelete.__name__]) @console_ns.response(204, "Credential deleted successfully") @@ -257,7 +256,7 @@ def post(self, current_tenant_id: str, provider: str): provider=provider, credential_id=args.credential_id, ) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/workspaces/current/model-providers//credentials/validate") @@ -265,8 +264,8 @@ class ModelProviderValidateApi(Resource): @console_ns.expect(console_ns.models[ParserCredentialValidate.__name__]) @console_ns.response( 200, - "Credential validation result", - console_ns.models[ProviderCredentialValidateResponse.__name__], + "Provider credentials validated successfully", + console_ns.models[ValidationResultResponse.__name__], ) @setup_required @login_required @@ -291,12 +290,10 @@ def post(self, current_tenant_id: str, provider: str): result = False error = str(ex) - response = {"result": "success" if result else "error"} - if not result: - response["error"] = error or "Unknown error" + return ValidationResultResponse(result="error", error=error or "Unknown error").model_dump(mode="json") - return response + return ValidationResultResponse(result="success").model_dump(mode="json") @console_ns.route("/workspaces//model-providers///") @@ -305,8 +302,9 @@ class ModelProviderIconApi(Resource): Get model provider icon """ - @console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__]) + @console_ns.response(200, "Model provider icon") def get(self, tenant_id: str, provider: str, icon_type: str, lang: str): + # response-contract:ignore binary send_file response model_provider_service = ModelProviderService() icon, mimetype = model_provider_service.get_model_provider_icon( tenant_id=tenant_id, @@ -338,12 +336,16 @@ def post(self, tenant_id: str, provider: str): tenant_id=tenant_id, provider=provider, preferred_provider_type=args.preferred_provider_type ) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/workspaces/current/model-providers//checkout-url") class ModelProviderPaymentCheckoutUrlApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ModelProviderPaymentCheckoutUrlResponse.__name__]) + @console_ns.response( + 200, + "Model provider checkout URL retrieved successfully", + console_ns.models[ModelProviderPaymentCheckoutUrlResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -359,4 +361,4 @@ def get(self, current_tenant_id: str, current_user: Account, provider: str): account_id=current_user.id, prefilled_email=current_user.email, ) - return data + return dump_response(ModelProviderPaymentCheckoutUrlResponse, data) diff --git a/api/controllers/console/workspace/models.py b/api/controllers/console/workspace/models.py index 1da72ef4362c98..52f86560de6133 100644 --- a/api/controllers/console/workspace/models.py +++ b/api/controllers/console/workspace/models.py @@ -5,7 +5,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field, field_validator -from controllers.common.fields import SimpleResultResponse +from controllers.common.fields import SimpleResultResponse, ValidationResultResponse from controllers.common.schema import ( query_params_from_model, register_enum_models, @@ -27,7 +27,6 @@ from fields.base import ResponseModel from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule from graphon.model_runtime.errors.validate import CredentialsValidateFailedError -from graphon.model_runtime.utils.encoders import jsonable_encoder from libs.helper import uuid_value from libs.login import login_required from models import Account @@ -62,7 +61,7 @@ class ParserDeleteModels(BaseModel): class LoadBalancingPayload(BaseModel): - configs: list[dict[str, Any]] | None = Field(default=None) + configs: list[dict[str, Any]] | None = None enabled: bool | None = None @@ -139,33 +138,38 @@ class DefaultModelDataResponse(ResponseModel): data: DefaultModelResponse | None = None -class ModelWithProviderListResponse(ResponseModel): +class ProviderModelListResponse(ResponseModel): data: list[ModelWithProviderEntityResponse] -class ProviderWithModelsDataResponse(ResponseModel): +class AvailableModelListResponse(ResponseModel): data: list[ProviderWithModelsResponse] -class ModelCredentialLoadBalancingResponse(ResponseModel): +class ModelLoadBalancingConfigResponse(ResponseModel): + id: str + name: str + credentials: dict[str, Any] + credential_id: str | None = None + enabled: bool + in_cooldown: bool + ttl: int + + +class ModelLoadBalancingResponse(ResponseModel): enabled: bool - configs: list[dict[str, Any]] = Field(default_factory=list) + configs: list[ModelLoadBalancingConfigResponse] class ModelCredentialResponse(ResponseModel): - credentials: dict[str, Any] = Field(default_factory=dict) + credentials: dict[str, Any] current_credential_id: str | None = None current_credential_name: str | None = None - load_balancing: ModelCredentialLoadBalancingResponse + load_balancing: ModelLoadBalancingResponse available_credentials: list[CredentialConfiguration] -class ModelCredentialValidateResponse(ResponseModel): - result: str - error: str | None = None - - -class ModelParameterRulesResponse(ResponseModel): +class ModelParameterRuleListResponse(ResponseModel): data: list[ParameterRule] @@ -186,12 +190,12 @@ class ModelParameterRulesResponse(ResponseModel): register_response_schema_models( console_ns, SimpleResultResponse, + ValidationResultResponse, DefaultModelDataResponse, - ModelWithProviderListResponse, - ProviderWithModelsDataResponse, + ProviderModelListResponse, ModelCredentialResponse, - ModelCredentialValidateResponse, - ModelParameterRulesResponse, + ModelParameterRuleListResponse, + AvailableModelListResponse, ) register_enum_models(console_ns, ModelType) @@ -200,7 +204,9 @@ class ModelParameterRulesResponse(ResponseModel): @console_ns.route("/workspaces/current/default-model") class DefaultModelApi(Resource): @console_ns.doc(params=query_params_from_model(ParserGetDefault)) - @console_ns.response(200, "Success", console_ns.models[DefaultModelDataResponse.__name__]) + @console_ns.response( + 200, "Default model retrieved successfully", console_ns.models[DefaultModelDataResponse.__name__] + ) @setup_required @login_required @account_initialization_required @@ -213,7 +219,7 @@ def get(self, tenant_id: str): tenant_id=tenant_id, model_type=args.model_type ) - return jsonable_encoder({"data": default_model_entity}) + return DefaultModelDataResponse(data=default_model_entity).model_dump(mode="json") @console_ns.expect(console_ns.models[ParserPostDefault.__name__]) @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @@ -246,12 +252,14 @@ def post(self, tenant_id: str): ) raise ex - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/workspaces/current/model-providers//models") class ModelProviderModelApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ModelWithProviderListResponse.__name__]) + @console_ns.response( + 200, "Provider models retrieved successfully", console_ns.models[ProviderModelListResponse.__name__] + ) @setup_required @login_required @account_initialization_required @@ -260,10 +268,10 @@ def get(self, tenant_id: str, provider: str): model_provider_service = ModelProviderService() models = model_provider_service.get_models_by_provider(tenant_id=tenant_id, provider=provider) - return jsonable_encoder({"data": models}) + return ProviderModelListResponse(data=models).model_dump(mode="json") @console_ns.expect(console_ns.models[ParserPostModels.__name__]) - @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(200, "Model updated successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -308,7 +316,7 @@ def post(self, tenant_id: str, provider: str): tenant_id=tenant_id, provider=provider, model=args.model, model_type=args.model_type ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @console_ns.expect(console_ns.models[ParserDeleteModels.__name__]) @console_ns.response(204, "Model deleted successfully") @@ -332,7 +340,11 @@ def delete(self, tenant_id: str, provider: str): @console_ns.route("/workspaces/current/model-providers//models/credentials") class ModelProviderModelCredentialApi(Resource): @console_ns.doc(params=query_params_from_model(ParserGetCredentials)) - @console_ns.response(200, "Success", console_ns.models[ModelCredentialResponse.__name__]) + @console_ns.response( + 200, + "Model credentials retrieved successfully", + console_ns.models[ModelCredentialResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -376,22 +388,23 @@ def get(self, tenant_id: str, user: Account, provider: str): model=args.model, ) - return jsonable_encoder( - { - "credentials": current_credential.get("credentials") if current_credential else {}, - "current_credential_id": current_credential.get("current_credential_id") - if current_credential - else None, - "current_credential_name": current_credential.get("current_credential_name") - if current_credential - else None, - "load_balancing": {"enabled": is_load_balancing_enabled, "configs": load_balancing_configs}, - "available_credentials": available_credentials, - } - ) + credentials: dict[str, Any] = {} + # TODO: make this throw error when type mismatches? + if current_credential and isinstance(current_credential.get("credentials"), dict): + credentials = cast(dict[str, Any], current_credential["credentials"]) + + return ModelCredentialResponse( + credentials=credentials, + current_credential_id=current_credential.get("current_credential_id") if current_credential else None, + current_credential_name=current_credential.get("current_credential_name") if current_credential else None, + load_balancing=ModelLoadBalancingResponse.model_validate( + {"enabled": is_load_balancing_enabled, "configs": load_balancing_configs} + ), + available_credentials=available_credentials, + ).model_dump(mode="json") @console_ns.expect(console_ns.models[ParserCreateCredential.__name__]) - @console_ns.response(201, "Credential created successfully", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(201, "Model credential created successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -421,10 +434,10 @@ def post(self, tenant_id: str, provider: str): ) raise ValueError(str(ex)) - return {"result": "success"}, 201 + return SimpleResultResponse(result="success").model_dump(mode="json"), 201 @console_ns.expect(console_ns.models[ParserUpdateCredential.__name__]) - @console_ns.response(200, "Credential updated successfully", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(200, "Model credential updated successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -449,7 +462,7 @@ def put(self, current_tenant_id: str, provider: str): except CredentialsValidateFailedError as ex: raise ValueError(str(ex)) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.expect(console_ns.models[ParserDeleteCredential.__name__]) @console_ns.response(204, "Credential deleted successfully") @@ -495,7 +508,7 @@ def post(self, current_tenant_id: str, provider: str): model=args.model, credential_id=args.credential_id, ) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route( @@ -517,7 +530,7 @@ def patch(self, tenant_id: str, provider: str): tenant_id=tenant_id, provider=provider, model=args.model, model_type=args.model_type ) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route( @@ -539,7 +552,7 @@ def patch(self, tenant_id: str, provider: str): tenant_id=tenant_id, provider=provider, model=args.model, model_type=args.model_type ) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") class ParserValidate(BaseModel): @@ -556,8 +569,8 @@ class ModelProviderModelValidateApi(Resource): @console_ns.expect(console_ns.models[ParserValidate.__name__]) @console_ns.response( 200, - "Credential validation result", - console_ns.models[ModelCredentialValidateResponse.__name__], + "Model credentials validated successfully", + console_ns.models[ValidationResultResponse.__name__], ) @setup_required @login_required @@ -583,18 +596,20 @@ def post(self, tenant_id: str, provider: str): result = False error = str(ex) - response = {"result": "success" if result else "error"} - if not result: - response["error"] = error or "" + return ValidationResultResponse(result="error", error=error or "").model_dump(mode="json") - return response + return ValidationResultResponse(result="success").model_dump(mode="json") @console_ns.route("/workspaces/current/model-providers//models/parameter-rules") class ModelProviderModelParameterRuleApi(Resource): @console_ns.doc(params=query_params_from_model(ParserParameter)) - @console_ns.response(200, "Success", console_ns.models[ModelParameterRulesResponse.__name__]) + @console_ns.response( + 200, + "Model parameter rules retrieved successfully", + console_ns.models[ModelParameterRuleListResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -607,12 +622,14 @@ def get(self, tenant_id: str, provider: str): tenant_id=tenant_id, provider=provider, model=args.model ) - return jsonable_encoder({"data": parameter_rules}) + return ModelParameterRuleListResponse(data=parameter_rules).model_dump(mode="json") @console_ns.route("/workspaces/current/models/model-types/") class ModelProviderAvailableModelApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ProviderWithModelsDataResponse.__name__]) + @console_ns.response( + 200, "Available models retrieved successfully", console_ns.models[AvailableModelListResponse.__name__] + ) @setup_required @login_required @account_initialization_required @@ -621,4 +638,4 @@ def get(self, tenant_id: str, model_type: str): model_provider_service = ModelProviderService() models = model_provider_service.get_models_by_model_type(tenant_id=tenant_id, model_type=model_type) - return jsonable_encoder({"data": models}) + return AvailableModelListResponse(data=models).model_dump(mode="json") diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index bcc1bb67459e67..161fc3ecd92703 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -31,7 +31,16 @@ with_current_user_id, ) from core.helper.position_helper import is_filtered -from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource +from core.plugin.entities.bundle import PluginBundleDependency +from core.plugin.entities.parameters import PluginParameterOption +from core.plugin.entities.plugin import ( + PluginCategory, + PluginDeclaration, + PluginEntity, + PluginInstallation, + PluginInstallationSource, +) +from core.plugin.entities.plugin_daemon import PluginDecodeResponse, PluginInstallTask, PluginInstallTaskStartResponse from core.plugin.impl.exc import PluginDaemonClientSideError from core.plugin.plugin_service import PluginService from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort @@ -292,33 +301,33 @@ class PluginCategoryListResponse(ResponseModel): has_more: bool -class PluginDaemonOperationResponse(RootModel[Any]): - root: Any +class PluginBundleUploadResponse(RootModel[list[PluginBundleDependency]]): + pass class PluginListResponse(ResponseModel): - plugins: Any + plugins: list[PluginEntity] total: int class PluginVersionsResponse(ResponseModel): - versions: Any + versions: Mapping[str, PluginService.LatestPluginCache | None] class PluginInstallationsResponse(ResponseModel): - plugins: Any + plugins: list[PluginInstallation] class PluginManifestResponse(ResponseModel): - manifest: Any + manifest: PluginDeclaration class PluginTasksResponse(ResponseModel): - tasks: Any + tasks: list[PluginInstallTask] class PluginTaskResponse(ResponseModel): - task: Any + task: PluginInstallTask class PluginPermissionResponse(ResponseModel): @@ -327,7 +336,7 @@ class PluginPermissionResponse(ResponseModel): class PluginDynamicOptionsResponse(ResponseModel): - options: Any + options: list[PluginParameterOption] class PluginOperationSuccessResponse(ResponseModel): @@ -374,10 +383,12 @@ class PluginReadmeResponse(ResponseModel): PluginCategoryBuiltinToolResponse, PluginCategoryInstalledPluginResponse, PluginCategoryListResponse, - PluginDaemonOperationResponse, + PluginBundleUploadResponse, + PluginDecodeResponse, PluginDebuggingKeyResponse, PluginDynamicOptionsResponse, PluginInstallationsResponse, + PluginInstallTaskStartResponse, PluginListResponse, PluginManifestResponse, PluginOperationSuccessResponse, @@ -611,7 +622,7 @@ def get(self, tenant_id: str): @console_ns.route("/workspaces/current/plugin/upload/pkg") class PluginUploadFromPkgApi(Resource): - @console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginDecodeResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -632,7 +643,7 @@ def post(self, tenant_id: str): @console_ns.route("/workspaces/current/plugin/upload/github") class PluginUploadFromGithubApi(Resource): @console_ns.expect(console_ns.models[ParserGithubUpload.__name__]) - @console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginDecodeResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -652,7 +663,7 @@ def post(self, tenant_id: str): @console_ns.route("/workspaces/current/plugin/upload/bundle") class PluginUploadFromBundleApi(Resource): - @console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginBundleUploadResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -673,7 +684,7 @@ def post(self, tenant_id: str): @console_ns.route("/workspaces/current/plugin/install/pkg") class PluginInstallFromPkgApi(Resource): @console_ns.expect(console_ns.models[ParserPluginIdentifiers.__name__]) - @console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginInstallTaskStartResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -694,7 +705,7 @@ def post(self, tenant_id: str): @console_ns.route("/workspaces/current/plugin/install/github") class PluginInstallFromGithubApi(Resource): @console_ns.expect(console_ns.models[ParserGithubInstall.__name__]) - @console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginInstallTaskStartResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -721,7 +732,7 @@ def post(self, tenant_id: str): @console_ns.route("/workspaces/current/plugin/install/marketplace") class PluginInstallFromMarketplaceApi(Resource): @console_ns.expect(console_ns.models[ParserPluginIdentifiers.__name__]) - @console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginInstallTaskStartResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -867,7 +878,7 @@ def post(self, tenant_id: str, task_id: str, identifier: str): @console_ns.route("/workspaces/current/plugin/upgrade/marketplace") class PluginUpgradeFromMarketplaceApi(Resource): @console_ns.expect(console_ns.models[ParserMarketplaceUpgrade.__name__]) - @console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginInstallTaskStartResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -890,7 +901,7 @@ def post(self, tenant_id: str): @console_ns.route("/workspaces/current/plugin/upgrade/github") class PluginUpgradeFromGithubApi(Resource): @console_ns.expect(console_ns.models[ParserGithubUpgrade.__name__]) - @console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginInstallTaskStartResponse.__name__]) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/workspace/snippets.py b/api/controllers/console/workspace/snippets.py index e8f0b228c8bd13..6e7c79f85ae5b8 100644 --- a/api/controllers/console/workspace/snippets.py +++ b/api/controllers/console/workspace/snippets.py @@ -1,16 +1,13 @@ import logging import re -from typing import Any from urllib.parse import quote from flask import Response, request -from flask_restx import Resource, marshal -from pydantic import RootModel +from flask_restx import Resource from sqlalchemy.orm import Session, sessionmaker from werkzeug.datastructures import MultiDict from werkzeug.exceptions import NotFound -from controllers.common.fields import TextFileResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.snippets.payloads import ( @@ -32,12 +29,13 @@ ) from extensions.ext_database import db from fields.base import ResponseModel -from fields.snippet_fields import snippet_fields, snippet_list_fields, snippet_pagination_fields +from fields.snippet_fields import SnippetPaginationResponse, SnippetResponse +from libs.helper import dump_response from libs.login import login_required from models import Account from models.snippet import SnippetType from services.app_dsl_service import ImportStatus -from services.snippet_dsl_service import SnippetDslService +from services.snippet_dsl_service import CheckDependenciesResult, SnippetDslService, SnippetImportInfo from services.snippet_service import SnippetService logger = logging.getLogger(__name__) @@ -45,15 +43,7 @@ _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$") -class SnippetImportResponse(RootModel[dict[str, Any]]): - root: dict[str, Any] - - -class SnippetDependencyCheckResponse(RootModel[dict[str, Any]]): - root: dict[str, Any] - - -class SnippetUseCountResponse(ResponseModel): +class SnippetUseCountIncrementResponse(ResponseModel): result: str use_count: int @@ -100,30 +90,22 @@ def _normalize_snippet_list_query_args(query_args: MultiDict[str, str]) -> dict[ IncludeSecretQuery, ) register_response_schema_models( - console_ns, - TextFileResponse, - SnippetImportResponse, - SnippetDependencyCheckResponse, - SnippetUseCountResponse, + console_ns, SnippetResponse, SnippetPaginationResponse, SnippetUseCountIncrementResponse, SnippetImportInfo ) -# Create namespace models for marshaling -snippet_model = console_ns.model("Snippet", snippet_fields) -snippet_list_model = console_ns.model("SnippetList", snippet_list_fields) -snippet_pagination_model = console_ns.model("SnippetPagination", snippet_pagination_fields) - @console_ns.route("/workspaces/current/customized-snippets") class CustomizedSnippetsApi(Resource): @console_ns.doc("list_customized_snippets") @console_ns.doc(params=query_params_from_model(SnippetListQuery)) - @console_ns.response(200, "Snippets retrieved successfully", snippet_pagination_model) + @console_ns.response(200, "Snippets retrieved successfully", console_ns.models[SnippetPaginationResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str): """List customized snippets with pagination and search.""" + query = SnippetListQuery.model_validate(_normalize_snippet_list_query_args(request.args)) snippet_service = _snippet_service() @@ -138,17 +120,20 @@ def get(self, current_tenant_id: str): tag_ids=query.tag_ids, ) - return { - "data": marshal(snippets, snippet_list_fields), - "page": query.page, - "limit": query.limit, - "total": total, - "has_more": has_more, - }, 200 + return dump_response( + SnippetPaginationResponse, + { + "data": snippets, + "page": query.page, + "limit": query.limit, + "total": total, + "has_more": has_more, + }, + ), 200 @console_ns.doc("create_customized_snippet") @console_ns.expect(console_ns.models.get(CreateSnippetPayload.__name__)) - @console_ns.response(201, "Snippet created successfully", snippet_model) + @console_ns.response(201, "Snippet created successfully", console_ns.models[SnippetResponse.__name__]) @console_ns.response(400, "Invalid request") @setup_required @login_required @@ -161,6 +146,7 @@ def get(self, current_tenant_id: str): @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account): """Create a new customized snippet.""" + payload = CreateSnippetPayload.model_validate(console_ns.payload or {}) try: @@ -185,13 +171,13 @@ def post(self, current_tenant_id: str, current_user: Account): except ValueError as e: return {"message": str(e)}, 400 - return marshal(snippet, snippet_fields), 201 + return dump_response(SnippetResponse, snippet), 201 @console_ns.route("/workspaces/current/customized-snippets/") class CustomizedSnippetDetailApi(Resource): @console_ns.doc("get_customized_snippet") - @console_ns.response(200, "Snippet retrieved successfully", snippet_model) + @console_ns.response(200, "Snippet retrieved successfully", console_ns.models[SnippetResponse.__name__]) @console_ns.response(404, "Snippet not found") @setup_required @login_required @@ -199,20 +185,21 @@ class CustomizedSnippetDetailApi(Resource): @with_current_tenant_id def get(self, current_tenant_id: str, snippet_id: str): """Get customized snippet details.""" + snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) if not snippet: raise NotFound("Snippet not found") - return marshal(snippet, snippet_fields), 200 + return dump_response(SnippetResponse, snippet), 200 @console_ns.doc("update_customized_snippet") @console_ns.expect(console_ns.models.get(UpdateSnippetPayload.__name__)) - @console_ns.response(200, "Snippet updated successfully", snippet_model) + @console_ns.response(200, "Snippet updated successfully", console_ns.models[SnippetResponse.__name__]) @console_ns.response(400, "Invalid request") @console_ns.response(404, "Snippet not found") @setup_required @@ -226,9 +213,10 @@ def get(self, current_tenant_id: str, snippet_id: str): @with_current_tenant_id def patch(self, current_tenant_id: str, current_user: Account, snippet_id: str): """Update customized snippet.""" + snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -257,7 +245,7 @@ def patch(self, current_tenant_id: str, current_user: Account, snippet_id: str): except ValueError as e: return {"message": str(e)}, 400 - return marshal(snippet, snippet_fields), 200 + return dump_response(SnippetResponse, snippet), 200 @console_ns.doc("delete_customized_snippet") @console_ns.response(204, "Snippet deleted successfully") @@ -270,9 +258,10 @@ def patch(self, current_tenant_id: str, current_user: Account, snippet_id: str): @with_current_tenant_id def delete(self, current_tenant_id: str, snippet_id: str): """Delete customized snippet.""" + snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -296,7 +285,7 @@ class CustomizedSnippetExportApi(Resource): @console_ns.doc(description="Export snippet configuration as DSL") @console_ns.doc(params={"snippet_id": "Snippet ID to export"}) @console_ns.doc(params=query_params_from_model(IncludeSecretQuery)) - @console_ns.response(200, "Snippet exported successfully", console_ns.models[TextFileResponse.__name__]) + @console_ns.response(200, "Snippet exported successfully") @console_ns.response(404, "Snippet not found") @setup_required @login_required @@ -308,9 +297,10 @@ class CustomizedSnippetExportApi(Resource): @with_current_tenant_id def get(self, current_tenant_id: str, snippet_id: str): """Export snippet as DSL.""" + snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -343,8 +333,8 @@ class CustomizedSnippetImportApi(Resource): @console_ns.doc("import_customized_snippet") @console_ns.doc(description="Import snippet from DSL") @console_ns.expect(console_ns.models.get(SnippetImportPayload.__name__)) - @console_ns.response(200, "Snippet imported successfully", console_ns.models[SnippetImportResponse.__name__]) - @console_ns.response(202, "Import pending confirmation", console_ns.models[SnippetImportResponse.__name__]) + @console_ns.response(200, "Snippet imported successfully", console_ns.models[SnippetImportInfo.__name__]) + @console_ns.response(202, "Import pending confirmation", console_ns.models[SnippetImportInfo.__name__]) @console_ns.response(400, "Import failed") @setup_required @login_required @@ -385,7 +375,7 @@ class CustomizedSnippetImportConfirmApi(Resource): @console_ns.doc("confirm_snippet_import") @console_ns.doc(description="Confirm a pending snippet import") @console_ns.doc(params={"import_id": "Import ID to confirm"}) - @console_ns.response(200, "Import confirmed successfully", console_ns.models[SnippetImportResponse.__name__]) + @console_ns.response(200, "Import confirmed successfully", console_ns.models[SnippetImportInfo.__name__]) @console_ns.response(400, "Import failed") @setup_required @login_required @@ -412,11 +402,7 @@ class CustomizedSnippetCheckDependenciesApi(Resource): @console_ns.doc("check_snippet_dependencies") @console_ns.doc(description="Check dependencies for a snippet") @console_ns.doc(params={"snippet_id": "Snippet ID"}) - @console_ns.response( - 200, - "Dependencies checked successfully", - console_ns.models[SnippetDependencyCheckResponse.__name__], - ) + @console_ns.response(200, "Dependencies checked successfully", console_ns.models[CheckDependenciesResult.__name__]) @console_ns.response(404, "Snippet not found") @setup_required @login_required @@ -430,7 +416,7 @@ def get(self, current_tenant_id: str, snippet_id: str): """Check dependencies for a snippet.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -449,7 +435,11 @@ class CustomizedSnippetUseCountIncrementApi(Resource): @console_ns.doc("increment_snippet_use_count") @console_ns.doc(description="Increment snippet use count by 1") @console_ns.doc(params={"snippet_id": "Snippet ID"}) - @console_ns.response(200, "Use count incremented successfully", console_ns.models[SnippetUseCountResponse.__name__]) + @console_ns.response( + 200, + "Use count incremented successfully", + console_ns.models[SnippetUseCountIncrementResponse.__name__], + ) @console_ns.response(404, "Snippet not found") @setup_required @login_required @@ -461,9 +451,10 @@ class CustomizedSnippetUseCountIncrementApi(Resource): @with_current_tenant_id def post(self, current_tenant_id: str, snippet_id: str): """Increment snippet use count when it is inserted into a workflow.""" + snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -476,4 +467,6 @@ def post(self, current_tenant_id: str, snippet_id: str): session.commit() session.refresh(snippet) - return {"result": "success", "use_count": snippet.use_count}, 200 + return SnippetUseCountIncrementResponse(result="success", use_count=snippet.use_count).model_dump( + mode="json" + ), 200 diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 4125e7d8de8caf..4e5380cc46ac0b 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -1,17 +1,31 @@ import io import logging -from typing import Any, Literal +from collections.abc import Iterable, Mapping +from datetime import datetime +from typing import Any, Literal, cast from urllib.parse import urlparse from flask import make_response, redirect, request, send_file from flask_restx import Resource -from pydantic import BaseModel, Field, HttpUrl, RootModel, field_validator, model_validator +from pydantic import ( + BaseModel, + Field, + HttpUrl, + RootModel, + field_validator, + model_validator, +) from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden from configs import dify_config -from controllers.common.fields import BinaryFileResponse, RedirectResponse, SimpleResultResponse -from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.common.fields import SimpleResultResponse +from controllers.common.schema import ( + query_params_from_model, + query_params_from_request, + register_response_schema_models, + register_schema_models, +) from controllers.console import console_ns from controllers.console.wraps import ( RBACPermission, @@ -26,22 +40,36 @@ ) from core.db.session_factory import session_factory from core.entities.mcp_provider import IdentityMode, MCPAuthentication, MCPConfiguration +from core.entities.provider_entities import ProviderConfig from core.mcp.auth.auth_flow import auth, handle_callback from core.mcp.error import MCPAuthError, MCPError, MCPRefreshTokenError from core.mcp.mcp_client import MCPClient from core.plugin.entities.plugin_daemon import CredentialType, PluginOAuthAuthorizationUrlResponse from core.plugin.impl.oauth import OAuthHandler -from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration +from core.tools.entities.api_entities import ( + ToolApiEntity, + ToolProviderCredentialApiEntity, + ToolProviderCredentialInfoApiEntity, + ToolProviderTypeApiLiteral, +) +from core.tools.entities.common_entities import I18nObject +from core.tools.entities.tool_bundle import ApiToolBundle +from core.tools.entities.tool_entities import ( + ApiProviderSchemaType, + ToolLabel, + ToolProviderType, + WorkflowToolParameterConfiguration, +) from extensions.ext_database import db -from graphon.model_runtime.utils.encoders import jsonable_encoder -from libs.helper import alphanumeric, uuid_value +from fields.base import ResponseModel +from libs.helper import alphanumeric, dump_response, uuid_value from libs.login import login_required from models import Account from models.provider_ids import ToolProviderID # from models.provider_ids import ToolProviderID from services.plugin.oauth_service import OAuthProxyService -from services.tools.api_tools_manage_service import ApiToolManageService +from services.tools.api_tools_manage_service import ApiToolManageService, ApiToolPreviewResult from services.tools.builtin_tools_manage_service import BuiltinToolManageService from services.tools.mcp_tools_manage_service import MCPToolManageService, OAuthDataType from services.tools.tool_labels_service import ToolLabelsService @@ -80,16 +108,21 @@ class BuiltinToolAddPayload(BaseModel): class BuiltinToolUpdatePayload(BaseModel): credential_id: str - credentials: dict[str, Any] | None = Field(default=None) + credentials: dict[str, Any] | None = None name: str | None = Field(default=None, max_length=30) +class ToolEmojiIcon(BaseModel): + background: str + content: str + + class ApiToolProviderBasePayload(BaseModel): credentials: dict[str, Any] schema_type: ApiProviderSchemaType schema_: str = Field(alias="schema") provider: str - icon: dict[str, Any] + icon: ToolEmojiIcon privacy_policy: str | None = None labels: list[str] | None = None custom_disclaimer: str = "" @@ -139,7 +172,7 @@ class WorkflowToolBasePayload(BaseModel): name: str label: str description: str - icon: dict[str, Any] + icon: ToolEmojiIcon parameters: list[WorkflowToolParameterConfiguration] = Field(default_factory=list) privacy_policy: str | None = "" labels: list[str] | None = None @@ -209,7 +242,7 @@ class BuiltinProviderDefaultCredentialPayload(BaseModel): class ToolOAuthCustomClientPayload(BaseModel): - client_params: dict[str, Any] | None = Field(default=None) + client_params: dict[str, Any] | None = None enable_oauth_custom_client: bool | None = True @@ -220,13 +253,20 @@ class MCPProviderBasePayload(BaseModel): icon_type: str icon_background: str = "" server_identifier: str - configuration: dict[str, Any] | None = Field(default_factory=dict) - headers: dict[str, Any] | None = Field(default_factory=dict) - authentication: dict[str, Any] | None = Field(default_factory=dict) + configuration: MCPConfiguration | None = None + headers: dict[str, str] | None = None + authentication: MCPAuthentication | None = None # None means "leave unchanged" on update; the controller resolves it to a # concrete IdentityMode before calling the service (see _resolve_identity_mode). identity_mode: IdentityMode | None = None + @field_validator("authentication", "configuration", mode="before") + @classmethod + def empty_to_none(cls, value: object) -> object: + if value == {}: + return None + return value + def _resolve_identity_mode(requested: IdentityMode | None, *, current: IdentityMode) -> IdentityMode: """Resolve the effective MCP identity_mode for a create/update request. @@ -271,16 +311,124 @@ class MCPCallbackQuery(BaseModel): state: str -class ToolOAuthCustomClientResponse(RootModel[dict[str, Any]]): - root: dict[str, Any] +class ApiProviderDetailResponse(ResponseModel): + schema_type: ApiProviderSchemaType + schema_: str = Field(alias="schema") + tools: list[ApiToolBundle] + icon: ToolEmojiIcon + description: str | None = None + credentials: Mapping[str, object] = Field(default_factory=dict) + privacy_policy: str | None = None + custom_disclaimer: str | None = None + labels: list[str] = Field(default_factory=list) + + +class ApiSchemaParseResponse(ResponseModel): + schema_type: ApiProviderSchemaType + parameters_schema: list[ApiToolBundle] + credentials_schema: list[ProviderConfig] + warning: dict[str, str] + + +class ApiProviderRemoteSchemaResponse(ResponseModel): + schema_: str = Field(alias="schema") + + +class ApiToolPreviewResponse(RootModel[ApiToolPreviewResult]): + pass + + +class BuiltinProviderOAuthClientSchemaResponse(ResponseModel): + schema_: list[ProviderConfig] = Field(alias="schema") + is_oauth_custom_client_enabled: bool + is_system_oauth_params_exists: bool + client_params: Mapping[str, object] | None = None + redirect_uri: str + + +class MCPAuthResponse(ResponseModel): + result: Literal["success"] | None = None + authorization_url: str | None = None + + +class ToolApiListResponse(RootModel[list[ToolApiEntity]]): + pass + + +# TODO: This duplicates core.tools.entities.api_entities.ToolProviderApiEntity's +# public response projection. Consolidate the core entity and controller response +# shape when the tool-provider API serialization boundary is cleaned up. +class ToolProviderApiEntityResponse(ResponseModel): + id: str + author: str + name: str + description: I18nObject + icon: str | Mapping[str, str] + icon_dark: str | Mapping[str, str] = "" + label: I18nObject + type: ToolProviderType + team_credentials: Mapping[str, object] = Field(default_factory=dict) + is_team_authorization: bool = False + allow_delete: bool = True + plugin_id: str | None = Field(default="", description="The plugin id of the tool") + plugin_unique_identifier: str | None = Field(default="", description="The unique identifier of the tool") + tools: list[ToolApiEntity] = Field(default_factory=list) + labels: list[str] = Field(default_factory=list) + server_url: str | None = Field(default="", description="The server url of the tool") + updated_at: int = Field(default_factory=lambda: int(datetime.now().timestamp())) + server_identifier: str | None = Field(default="", description="The server identifier of the MCP tool") + masked_headers: dict[str, str] | None = Field(default=None, description="The masked headers of the MCP tool") + original_headers: dict[str, str] | None = Field(default=None, description="The original headers of the MCP tool") + authentication: MCPAuthentication | None = Field(default=None, description="The OAuth config of the MCP tool") + is_dynamic_registration: bool = Field(default=True, description="Whether the MCP tool is dynamically registered") + configuration: MCPConfiguration | None = Field( + default=None, description="The timeout and sse_read_timeout of the MCP tool" + ) + identity_mode: str = Field(default="off", description="Identity-forwarding mechanism: 'off' or 'idp_token'") + workflow_app_id: str | None = Field(default=None, description="The app id of the workflow tool") + + @field_validator("tools", mode="before") + @classmethod + def convert_none_to_empty_list(cls, value: list[ToolApiEntity] | None) -> list[ToolApiEntity]: + return value if value is not None else [] + + +class ToolProviderListResponse(RootModel[list[ToolProviderApiEntityResponse]]): + pass + + +def _dump_tool_provider_payload(payload: Mapping[str, Any]) -> dict[str, Any]: + return ToolProviderApiEntityResponse.model_validate(payload).model_dump(mode="json", exclude_unset=True) + + +def _dump_tool_provider_payload_list(payloads: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: + return [_dump_tool_provider_payload(payload) for payload in payloads] + + +class ToolProviderCredentialListResponse(RootModel[list[ToolProviderCredentialApiEntity]]): + pass + +class ProviderConfigListResponse(RootModel[list[ProviderConfig]]): + pass -class ToolOAuthClientSchemaResponse(RootModel[list[dict[str, Any]]]): - root: list[dict[str, Any]] +class ToolLabelListResponse(RootModel[list[ToolLabel]]): + pass -class ToolProviderOpaqueResponse(RootModel[Any]): - root: Any + +class WorkflowToolDetailResponse(ResponseModel): + name: str + label: str + workflow_tool_id: str + workflow_app_id: str + icon: ToolEmojiIcon + description: str + parameters: list[WorkflowToolParameterConfiguration] + output_schema: Mapping[str, object] = Field(default_factory=dict) + tool: ToolApiEntity + synced: bool + privacy_policy: str | None = None register_schema_models( @@ -292,6 +440,7 @@ class ToolProviderOpaqueResponse(RootModel[Any]): WorkflowToolGetQuery, WorkflowToolListQuery, MCPCallbackQuery, + ToolEmojiIcon, BuiltinToolCredentialDeletePayload, BuiltinToolAddPayload, BuiltinToolUpdatePayload, @@ -312,63 +461,94 @@ class ToolProviderOpaqueResponse(RootModel[Any]): ) register_response_schema_models( console_ns, - BinaryFileResponse, - PluginOAuthAuthorizationUrlResponse, - RedirectResponse, SimpleResultResponse, - ToolOAuthClientSchemaResponse, - ToolOAuthCustomClientResponse, - ToolProviderOpaqueResponse, + ApiProviderDetailResponse, + ApiSchemaParseResponse, + ApiProviderRemoteSchemaResponse, + ApiToolPreviewResponse, + BuiltinProviderOAuthClientSchemaResponse, + ToolApiListResponse, + ToolProviderApiEntityResponse, + ToolProviderCredentialInfoApiEntity, + ToolProviderCredentialListResponse, + ToolProviderListResponse, + ProviderConfigListResponse, + PluginOAuthAuthorizationUrlResponse, + ToolLabelListResponse, + MCPAuthResponse, + WorkflowToolDetailResponse, ) @console_ns.route("/workspaces/current/tool-providers") class ToolProviderListApi(Resource): @console_ns.doc(params=query_params_from_model(ToolProviderListQuery)) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "Tool providers retrieved successfully", console_ns.models[ToolProviderListResponse.__name__] + ) @setup_required @login_required @account_initialization_required @with_current_user @with_current_tenant_id def get(self, tenant_id: str, user: Account): - raw_args = request.args.to_dict() - query = ToolProviderListQuery.model_validate(raw_args) + query = query_params_from_request(ToolProviderListQuery) - return ToolCommonService.list_tool_providers(user.id, tenant_id, query.type) # type: ignore + return _dump_tool_provider_payload_list( + ToolCommonService.list_tool_providers( + user.id, tenant_id, cast(ToolProviderTypeApiLiteral | None, query.type) + ), + ) @console_ns.route("/workspaces/current/tool-provider/builtin//tools") class ToolBuiltinProviderListToolsApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Builtin provider tools retrieved successfully", + console_ns.models[ToolApiListResponse.__name__], + ) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, provider: str): - return jsonable_encoder( + + return dump_response( + ToolApiListResponse, BuiltinToolManageService.list_builtin_tool_provider_tools( tenant_id, provider, - ) + ), ) @console_ns.route("/workspaces/current/tool-provider/builtin//info") class ToolBuiltinProviderInfoApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Builtin provider info retrieved successfully", + console_ns.models[ToolProviderApiEntityResponse.__name__], + ) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, provider: str): - return jsonable_encoder(BuiltinToolManageService.get_builtin_tool_provider_info(tenant_id, provider)) + + return _dump_tool_provider_payload( + BuiltinToolManageService.get_builtin_tool_provider_info(tenant_id, provider).to_dict() + ) @console_ns.route("/workspaces/current/tool-provider/builtin//delete") class ToolBuiltinProviderDeleteApi(Resource): @console_ns.expect(console_ns.models[BuiltinToolCredentialDeletePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Builtin provider credential deleted successfully", + console_ns.models[SimpleResultResponse.__name__], + ) @setup_required @login_required @is_admin_or_owner_required @@ -376,19 +556,27 @@ class ToolBuiltinProviderDeleteApi(Resource): @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): + payload = BuiltinToolCredentialDeletePayload.model_validate(console_ns.payload or {}) - return BuiltinToolManageService.delete_builtin_tool_provider( - tenant_id, - provider, - payload.credential_id, + return dump_response( + SimpleResultResponse, + BuiltinToolManageService.delete_builtin_tool_provider( + tenant_id, + provider, + payload.credential_id, + ), ) @console_ns.route("/workspaces/current/tool-provider/builtin//add") class ToolBuiltinProviderAddApi(Resource): @console_ns.expect(console_ns.models[BuiltinToolAddPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Builtin provider added successfully", + console_ns.models[SimpleResultResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -397,21 +585,28 @@ class ToolBuiltinProviderAddApi(Resource): def post(self, tenant_id: str, user: Account, provider: str): payload = BuiltinToolAddPayload.model_validate(console_ns.payload or {}) - return BuiltinToolManageService.add_builtin_tool_provider( - user_id=user.id, - tenant_id=tenant_id, - provider=provider, - credentials=payload.credentials, - name=payload.name, - api_type=CredentialType.of(payload.type), - visibility=payload.visibility, + return dump_response( + SimpleResultResponse, + BuiltinToolManageService.add_builtin_tool_provider( + user_id=user.id, + tenant_id=tenant_id, + provider=provider, + credentials=payload.credentials, + name=payload.name, + api_type=CredentialType.of(payload.type), + visibility=payload.visibility, + ), ) @console_ns.route("/workspaces/current/tool-provider/builtin//update") class ToolBuiltinProviderUpdateApi(Resource): @console_ns.expect(console_ns.models[BuiltinToolUpdatePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Builtin provider updated successfully", + console_ns.models[SimpleResultResponse.__name__], + ) @setup_required @login_required @is_admin_or_owner_required @@ -430,13 +625,17 @@ def post(self, tenant_id: str, user: Account, provider: str): credentials=payload.credentials, name=payload.name or "", ) - return result + return dump_response(SimpleResultResponse, result) @console_ns.route("/workspaces/current/tool-provider/builtin//credentials") class ToolBuiltinProviderGetCredentialsApi(Resource): @console_ns.doc(params=query_params_from_model(BuiltinCredentialListQuery)) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Builtin provider credentials retrieved successfully", + console_ns.models[ToolProviderCredentialListResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -445,34 +644,34 @@ class ToolBuiltinProviderGetCredentialsApi(Resource): def get(self, tenant_id: str, user: Account, provider: str): # Optional list of credential IDs to include even if visibility would hide them # (used when a workflow/agent node still references another member's only_me credential). - include_credential_ids = request.args.getlist("include_credential_ids") or [ - s for s in (request.args.get("include_credential_ids") or "").split(",") if s - ] + query = query_params_from_request(BuiltinCredentialListQuery, list_fields=("include_credential_ids",)) - return jsonable_encoder( + return dump_response( + ToolProviderCredentialListResponse, BuiltinToolManageService.get_builtin_tool_provider_credentials( tenant_id=tenant_id, provider_name=provider, user=user, - include_credential_ids=include_credential_ids or None, - ) + include_credential_ids=query.include_credential_ids or None, + ), ) @console_ns.route("/workspaces/current/tool-provider/builtin//icon") class ToolBuiltinProviderIconApi(Resource): - @console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__]) + @console_ns.response(200, "Builtin provider icon") @setup_required def get(self, provider: str): icon_bytes, mimetype = BuiltinToolManageService.get_builtin_tool_provider_icon(provider) icon_cache_max_age = dify_config.TOOL_ICON_CACHE_MAX_AGE + # response-contract:ignore binary send_file response return send_file(io.BytesIO(icon_bytes), mimetype=mimetype, max_age=icon_cache_max_age) @console_ns.route("/workspaces/current/tool-provider/api/add") class ToolApiProviderAddApi(Resource): @console_ns.expect(console_ns.models[ApiToolProviderAddPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "API provider added successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -483,66 +682,77 @@ class ToolApiProviderAddApi(Resource): def post(self, tenant_id: str, user: Account): payload = ApiToolProviderAddPayload.model_validate(console_ns.payload or {}) - return ApiToolManageService.create_api_tool_provider( - user.id, - tenant_id, - payload.provider, - payload.icon, - payload.credentials, - payload.schema_type, - payload.schema_, - payload.privacy_policy or "", - payload.custom_disclaimer or "", - payload.labels or [], + return dump_response( + SimpleResultResponse, + ApiToolManageService.create_api_tool_provider( + user.id, + tenant_id, + payload.provider, + payload.icon.model_dump(mode="json"), + payload.credentials, + payload.schema_type, + payload.schema_, + payload.privacy_policy or "", + payload.custom_disclaimer or "", + payload.labels or [], + ), ) @console_ns.route("/workspaces/current/tool-provider/api/remote") class ToolApiProviderGetRemoteSchemaApi(Resource): @console_ns.doc(params=query_params_from_model(UrlQuery)) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Remote API provider schema retrieved successfully", + console_ns.models[ApiProviderRemoteSchemaResponse.__name__], + ) @setup_required @login_required @account_initialization_required @with_current_user @with_current_tenant_id def get(self, tenant_id: str, user: Account): - raw_args = request.args.to_dict() - query = UrlQuery.model_validate(raw_args) + query = query_params_from_request(UrlQuery) - return ApiToolManageService.get_api_tool_provider_remote_schema( - user.id, - tenant_id, - str(query.url), + return dump_response( + ApiProviderRemoteSchemaResponse, + ApiToolManageService.get_api_tool_provider_remote_schema( + user.id, + tenant_id, + str(query.url), + ), ) @console_ns.route("/workspaces/current/tool-provider/api/tools") class ToolApiProviderListToolsApi(Resource): @console_ns.doc(params=query_params_from_model(ProviderQuery)) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "API provider tools retrieved successfully", console_ns.models[ToolApiListResponse.__name__] + ) @setup_required @login_required @account_initialization_required @with_current_user @with_current_tenant_id def get(self, tenant_id: str, user: Account): - raw_args = request.args.to_dict() - query = ProviderQuery.model_validate(raw_args) + query = query_params_from_request(ProviderQuery) - return jsonable_encoder( + return dump_response( + ToolApiListResponse, ApiToolManageService.list_api_tool_provider_tools( user.id, tenant_id, query.provider, - ) + ), ) @console_ns.route("/workspaces/current/tool-provider/api/update") class ToolApiProviderUpdateApi(Resource): @console_ns.expect(console_ns.models[ApiToolProviderUpdatePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "API provider updated successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -553,25 +763,28 @@ class ToolApiProviderUpdateApi(Resource): def post(self, tenant_id: str, user: Account): payload = ApiToolProviderUpdatePayload.model_validate(console_ns.payload or {}) - return ApiToolManageService.update_api_tool_provider( - user.id, - tenant_id, - payload.provider, - payload.original_provider, - payload.icon, - payload.credentials, - payload.schema_type, - payload.schema_, - payload.privacy_policy, - payload.custom_disclaimer, - payload.labels or [], + return dump_response( + SimpleResultResponse, + ApiToolManageService.update_api_tool_provider( + user.id, + tenant_id, + payload.provider, + payload.original_provider, + payload.icon.model_dump(mode="json"), + payload.credentials, + payload.schema_type, + payload.schema_, + payload.privacy_policy, + payload.custom_disclaimer, + payload.labels or [], + ), ) @console_ns.route("/workspaces/current/tool-provider/api/delete") class ToolApiProviderDeleteApi(Resource): @console_ns.expect(console_ns.models[ApiToolProviderDeletePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "API provider deleted successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -582,88 +795,106 @@ class ToolApiProviderDeleteApi(Resource): def post(self, tenant_id: str, user: Account): payload = ApiToolProviderDeletePayload.model_validate(console_ns.payload or {}) - return ApiToolManageService.delete_api_tool_provider( - user.id, - tenant_id, - payload.provider, + return dump_response( + SimpleResultResponse, + ApiToolManageService.delete_api_tool_provider( + user.id, + tenant_id, + payload.provider, + ), ) @console_ns.route("/workspaces/current/tool-provider/api/get") class ToolApiProviderGetApi(Resource): @console_ns.doc(params=query_params_from_model(ProviderQuery)) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "API provider retrieved successfully", console_ns.models[ApiProviderDetailResponse.__name__] + ) @setup_required @login_required @account_initialization_required @with_current_user @with_current_tenant_id def get(self, tenant_id: str, user: Account): - raw_args = request.args.to_dict() - query = ProviderQuery.model_validate(raw_args) + query = query_params_from_request(ProviderQuery) - return ApiToolManageService.get_api_tool_provider( - user.id, - tenant_id, - query.provider, + return dump_response( + ApiProviderDetailResponse, + ApiToolManageService.get_api_tool_provider( + user.id, + tenant_id, + query.provider, + ), ) @console_ns.route("/workspaces/current/tool-provider/builtin//credential/schema/") class ToolBuiltinProviderCredentialsSchemaApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Builtin provider credential schema retrieved successfully", + console_ns.models[ProviderConfigListResponse.__name__], + ) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, provider, credential_type): - return jsonable_encoder( + + return dump_response( + ProviderConfigListResponse, BuiltinToolManageService.list_builtin_provider_credentials_schema( provider, CredentialType.of(credential_type), tenant_id - ) + ), ) @console_ns.route("/workspaces/current/tool-provider/api/schema") class ToolApiProviderSchemaApi(Resource): @console_ns.expect(console_ns.models[ApiToolSchemaPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "API schema parsed successfully", console_ns.models[ApiSchemaParseResponse.__name__]) @setup_required @login_required @account_initialization_required def post(self): payload = ApiToolSchemaPayload.model_validate(console_ns.payload or {}) - return ApiToolManageService.parser_api_schema( - schema=payload.schema_, - ) + return dump_response(ApiSchemaParseResponse, ApiToolManageService.parser_api_schema(schema=payload.schema_)) @console_ns.route("/workspaces/current/tool-provider/api/test/pre") class ToolApiProviderPreviousTestApi(Resource): @console_ns.expect(console_ns.models[ApiToolTestPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "API tool test preview completed successfully", + console_ns.models[ApiToolPreviewResponse.__name__], + ) @setup_required @login_required @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str): payload = ApiToolTestPayload.model_validate(console_ns.payload or {}) - return ApiToolManageService.test_api_tool_preview( - current_tenant_id, - payload.provider_name or "", - payload.tool_name, - payload.credentials, - payload.parameters, - payload.schema_type, - payload.schema_, + return dump_response( + ApiToolPreviewResponse, + ApiToolManageService.test_api_tool_preview( + current_tenant_id, + payload.provider_name or "", + payload.tool_name, + payload.credentials, + payload.parameters, + payload.schema_type, + payload.schema_, + ), ) @console_ns.route("/workspaces/current/tool-provider/workflow/create") class ToolWorkflowProviderCreateApi(Resource): @console_ns.expect(console_ns.models[WorkflowToolCreatePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "Workflow tool created successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -674,24 +905,27 @@ class ToolWorkflowProviderCreateApi(Resource): def post(self, tenant_id: str, user: Account): payload = WorkflowToolCreatePayload.model_validate(console_ns.payload or {}) - return WorkflowToolManageService.create_workflow_tool( - user_id=user.id, - tenant_id=tenant_id, - workflow_app_id=payload.workflow_app_id, - name=payload.name, - label=payload.label, - icon=payload.icon, - description=payload.description, - parameters=payload.parameters, - privacy_policy=payload.privacy_policy or "", - labels=payload.labels or [], + return dump_response( + SimpleResultResponse, + WorkflowToolManageService.create_workflow_tool( + user_id=user.id, + tenant_id=tenant_id, + workflow_app_id=payload.workflow_app_id, + name=payload.name, + label=payload.label, + icon=payload.icon.model_dump(mode="json"), + description=payload.description, + parameters=payload.parameters, + privacy_policy=payload.privacy_policy or "", + labels=payload.labels or [], + ), ) @console_ns.route("/workspaces/current/tool-provider/workflow/update") class ToolWorkflowProviderUpdateApi(Resource): @console_ns.expect(console_ns.models[WorkflowToolUpdatePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "Workflow tool updated successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -702,24 +936,27 @@ class ToolWorkflowProviderUpdateApi(Resource): def post(self, tenant_id: str, user: Account): payload = WorkflowToolUpdatePayload.model_validate(console_ns.payload or {}) - return WorkflowToolManageService.update_workflow_tool( - user.id, - tenant_id, - payload.workflow_tool_id, - payload.name, - payload.label, - payload.icon, - payload.description, - payload.parameters, - payload.privacy_policy or "", - payload.labels or [], + return dump_response( + SimpleResultResponse, + WorkflowToolManageService.update_workflow_tool( + user.id, + tenant_id, + payload.workflow_tool_id, + payload.name, + payload.label, + payload.icon.model_dump(mode="json"), + payload.description, + payload.parameters, + payload.privacy_policy or "", + payload.labels or [], + ), ) @console_ns.route("/workspaces/current/tool-provider/workflow/delete") class ToolWorkflowProviderDeleteApi(Resource): @console_ns.expect(console_ns.models[WorkflowToolDeletePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "Workflow tool deleted successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -730,25 +967,29 @@ class ToolWorkflowProviderDeleteApi(Resource): def post(self, tenant_id: str, user: Account): payload = WorkflowToolDeletePayload.model_validate(console_ns.payload or {}) - return WorkflowToolManageService.delete_workflow_tool( - user.id, - tenant_id, - payload.workflow_tool_id, + return dump_response( + SimpleResultResponse, + WorkflowToolManageService.delete_workflow_tool( + user.id, + tenant_id, + payload.workflow_tool_id, + ), ) @console_ns.route("/workspaces/current/tool-provider/workflow/get") class ToolWorkflowProviderGetApi(Resource): @console_ns.doc(params=query_params_from_model(WorkflowToolGetQuery)) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "Workflow tool retrieved successfully", console_ns.models[WorkflowToolDetailResponse.__name__] + ) @setup_required @login_required @account_initialization_required @with_current_user @with_current_tenant_id def get(self, tenant_id: str, user: Account): - raw_args = request.args.to_dict() - query = WorkflowToolGetQuery.model_validate(raw_args) + query = query_params_from_request(WorkflowToolGetQuery) if query.workflow_tool_id: tool = WorkflowToolManageService.get_workflow_tool_by_tool_id( @@ -765,105 +1006,112 @@ def get(self, tenant_id: str, user: Account): else: raise ValueError("incorrect workflow_tool_id or workflow_app_id") - return jsonable_encoder(tool) + return dump_response(WorkflowToolDetailResponse, tool) @console_ns.route("/workspaces/current/tool-provider/workflow/tools") class ToolWorkflowProviderListToolApi(Resource): @console_ns.doc(params=query_params_from_model(WorkflowToolListQuery)) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "Workflow provider tools retrieved successfully", console_ns.models[ToolApiListResponse.__name__] + ) @setup_required @login_required @account_initialization_required @with_current_user @with_current_tenant_id def get(self, tenant_id: str, user: Account): - raw_args = request.args.to_dict() - query = WorkflowToolListQuery.model_validate(raw_args) + query = query_params_from_request(WorkflowToolListQuery) - return jsonable_encoder( + return dump_response( + ToolApiListResponse, WorkflowToolManageService.list_single_workflow_tools( user.id, tenant_id, query.workflow_tool_id, - ) + ), ) @console_ns.route("/workspaces/current/tools/builtin") class ToolBuiltinListApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "Builtin tools retrieved successfully", console_ns.models[ToolProviderListResponse.__name__] + ) @setup_required @login_required @account_initialization_required @with_current_user @with_current_tenant_id def get(self, tenant_id: str, user: Account): - return jsonable_encoder( + return _dump_tool_provider_payload_list( [ provider.to_dict() for provider in BuiltinToolManageService.list_builtin_tools( user.id, tenant_id, ) - ] + ], ) @console_ns.route("/workspaces/current/tools/api") class ToolApiListApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "API tools retrieved successfully", console_ns.models[ToolProviderListResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str): - return jsonable_encoder( + + return _dump_tool_provider_payload_list( [ provider.to_dict() for provider in ApiToolManageService.list_api_tools( tenant_id, ) - ] + ], ) @console_ns.route("/workspaces/current/tools/workflow") class ToolWorkflowListApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "Workflow tools retrieved successfully", console_ns.models[ToolProviderListResponse.__name__] + ) @setup_required @login_required @account_initialization_required @with_current_user @with_current_tenant_id def get(self, tenant_id: str, user: Account): - return jsonable_encoder( + return _dump_tool_provider_payload_list( [ provider.to_dict() for provider in WorkflowToolManageService.list_tenant_workflow_tools( user.id, tenant_id, ) - ] + ], ) @console_ns.route("/workspaces/current/tool-labels") class ToolLabelsApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "Tool labels retrieved successfully", console_ns.models[ToolLabelListResponse.__name__]) @setup_required @login_required @account_initialization_required @enterprise_license_required def get(self): - return jsonable_encoder(ToolLabelsService.list_tool_labels()) + return dump_response(ToolLabelListResponse, ToolLabelsService.list_tool_labels()) @console_ns.route("/oauth/plugin//tool/authorization-url") class ToolPluginOAuthApi(Resource): @console_ns.response( 200, - "Authorization URL retrieved successfully", + "Tool OAuth authorization URL generated successfully", console_ns.models[PluginOAuthAuthorizationUrlResponse.__name__], ) @setup_required @@ -895,7 +1143,8 @@ def get(self, tenant_id: str, user: Account, provider: str): redirect_uri=redirect_uri, system_credentials=oauth_client_params, ) - response = make_response(jsonable_encoder(authorization_url_response)) + # response-contract:ignore cookie-bearing Flask response + response = make_response(dump_response(PluginOAuthAuthorizationUrlResponse, authorization_url_response)) response.set_cookie( "context_id", context_id, @@ -908,11 +1157,7 @@ def get(self, tenant_id: str, user: Account, provider: str): @console_ns.route("/oauth/plugin//tool/callback") class ToolOAuthCallback(Resource): - @console_ns.response( - 302, - "Redirect to console OAuth callback page", - console_ns.models[RedirectResponse.__name__], - ) + @console_ns.response(302, "Redirect to OAuth callback page") @setup_required def get(self, provider: str): context_id = request.cookies.get("context_id") @@ -961,13 +1206,14 @@ def get(self, provider: str): api_type=CredentialType.OAUTH2, visibility="only_me", ) + # response-contract:ignore redirect response return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback") @console_ns.route("/workspaces/current/tool-provider/builtin//default-credential") class ToolBuiltinProviderSetDefaultApi(Resource): @console_ns.expect(console_ns.models[BuiltinProviderDefaultCredentialPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "Default credential set successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -976,15 +1222,20 @@ class ToolBuiltinProviderSetDefaultApi(Resource): @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): payload = BuiltinProviderDefaultCredentialPayload.model_validate(console_ns.payload or {}) - return BuiltinToolManageService.set_default_provider( - tenant_id=current_tenant_id, provider=provider, id=payload.id + return dump_response( + SimpleResultResponse, + BuiltinToolManageService.set_default_provider( + tenant_id=current_tenant_id, provider=provider, id=payload.id + ), ) @console_ns.route("/workspaces/current/tool-provider/builtin//oauth/custom-client") class ToolOAuthCustomClient(Resource): @console_ns.expect(console_ns.models[ToolOAuthCustomClientPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response( + 200, "Custom OAuth client saved successfully", console_ns.models[SimpleResultResponse.__name__] + ) @setup_required @login_required @is_admin_or_owner_required @@ -994,79 +1245,96 @@ class ToolOAuthCustomClient(Resource): def post(self, tenant_id: str, provider: str): payload = ToolOAuthCustomClientPayload.model_validate(console_ns.payload or {}) - return BuiltinToolManageService.save_custom_oauth_client_params( - tenant_id=tenant_id, - provider=provider, - client_params=payload.client_params or {}, - enable_oauth_custom_client=payload.enable_oauth_custom_client - if payload.enable_oauth_custom_client is not None - else True, + return dump_response( + SimpleResultResponse, + BuiltinToolManageService.save_custom_oauth_client_params( + tenant_id=tenant_id, + provider=provider, + client_params=payload.client_params or {}, + enable_oauth_custom_client=payload.enable_oauth_custom_client + if payload.enable_oauth_custom_client is not None + else True, + ), ) + @console_ns.response( + 200, + "Custom OAuth client retrieved successfully", + ) @setup_required @login_required @account_initialization_required - @console_ns.response(200, "Success", console_ns.models[ToolOAuthCustomClientResponse.__name__]) @with_current_tenant_id def get(self, current_tenant_id: str, provider: str): - return jsonable_encoder( - BuiltinToolManageService.get_custom_oauth_client_params(tenant_id=current_tenant_id, provider=provider) - ) + return BuiltinToolManageService.get_custom_oauth_client_params(tenant_id=current_tenant_id, provider=provider) + @console_ns.response( + 200, "Custom OAuth client deleted successfully", console_ns.models[SimpleResultResponse.__name__] + ) @setup_required @login_required @account_initialization_required - @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @with_current_tenant_id def delete(self, current_tenant_id: str, provider: str): - return jsonable_encoder( - BuiltinToolManageService.delete_custom_oauth_client_params(tenant_id=current_tenant_id, provider=provider) + return dump_response( + SimpleResultResponse, + BuiltinToolManageService.delete_custom_oauth_client_params(tenant_id=current_tenant_id, provider=provider), ) @console_ns.route("/workspaces/current/tool-provider/builtin//oauth/client-schema") class ToolBuiltinProviderGetOauthClientSchemaApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ToolOAuthClientSchemaResponse.__name__]) + @console_ns.response( + 200, + "Builtin provider OAuth client schema retrieved successfully", + console_ns.models[BuiltinProviderOAuthClientSchemaResponse.__name__], + ) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str, provider: str): - return jsonable_encoder( + return dump_response( + BuiltinProviderOAuthClientSchemaResponse, BuiltinToolManageService.get_builtin_tool_provider_oauth_client_schema( tenant_id=current_tenant_id, provider_name=provider - ) + ), ) @console_ns.route("/workspaces/current/tool-provider/builtin//credential/info") class ToolBuiltinProviderGetCredentialInfoApi(Resource): @console_ns.doc(params=query_params_from_model(BuiltinCredentialListQuery)) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Builtin provider credential info retrieved successfully", + console_ns.models[ToolProviderCredentialInfoApiEntity.__name__], + ) @setup_required @login_required @account_initialization_required @with_current_user @with_current_tenant_id def get(self, tenant_id: str, user: Account, provider: str): - include_credential_ids = request.args.getlist("include_credential_ids") or [ - s for s in (request.args.get("include_credential_ids") or "").split(",") if s - ] + query = query_params_from_request(BuiltinCredentialListQuery, list_fields=("include_credential_ids",)) - return jsonable_encoder( + return dump_response( + ToolProviderCredentialInfoApiEntity, BuiltinToolManageService.get_builtin_tool_provider_credential_info( tenant_id=tenant_id, provider=provider, user=user, - include_credential_ids=include_credential_ids or None, - ) + include_credential_ids=query.include_credential_ids or None, + ), ) @console_ns.route("/workspaces/current/tool-provider/mcp") class ToolProviderMCPApi(Resource): @console_ns.expect(console_ns.models[MCPProviderCreatePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "MCP provider created successfully", console_ns.models[ToolProviderApiEntityResponse.__name__] + ) @setup_required @login_required @account_initialization_required @@ -1076,9 +1344,8 @@ class ToolProviderMCPApi(Resource): def post(self, tenant_id: str, user: Account): payload = MCPProviderCreatePayload.model_validate(console_ns.payload or {}) - # Parse and validate models - configuration = MCPConfiguration.model_validate(payload.configuration or {}) - authentication = MCPAuthentication.model_validate(payload.authentication) if payload.authentication else None + configuration = payload.configuration or MCPConfiguration() + authentication = payload.authentication # 1) Create provider in a short transaction (no network I/O inside) with session_factory.create_session() as session, session.begin(): @@ -1119,10 +1386,10 @@ def post(self, tenant_id: str, user: Account): # Best-effort: if initial fetch fails (e.g., auth required), return created provider as-is logger.warning("Failed to fetch MCP tools after creation", exc_info=True) - return jsonable_encoder(result) + return _dump_tool_provider_payload(result.to_dict()) @console_ns.expect(console_ns.models[MCPProviderUpdatePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(200, "MCP provider updated successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -1130,8 +1397,8 @@ def post(self, tenant_id: str, user: Account): @with_current_tenant_id def put(self, current_tenant_id: str): payload = MCPProviderUpdatePayload.model_validate(console_ns.payload or {}) - configuration = MCPConfiguration.model_validate(payload.configuration or {}) - authentication = MCPAuthentication.model_validate(payload.authentication) if payload.authentication else None + configuration = payload.configuration or MCPConfiguration() + authentication = payload.authentication # Step 1: Get provider data for URL validation (short-lived session, no network I/O) validation_data = None @@ -1173,7 +1440,7 @@ def put(self, current_tenant_id: str): identity_mode=identity_mode, ) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.expect(console_ns.models[MCPProviderDeletePayload.__name__]) @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @@ -1189,13 +1456,13 @@ def delete(self, current_tenant_id: str): service = MCPToolManageService(session=session) service.delete_provider(tenant_id=current_tenant_id, provider_id=payload.provider_id) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") @console_ns.route("/workspaces/current/tool-provider/mcp/auth") class ToolMCPAuthApi(Resource): @console_ns.expect(console_ns.models[MCPAuthPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "MCP provider authorized successfully", console_ns.models[MCPAuthResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -1234,7 +1501,7 @@ def post(self, tenant_id: str): credentials=provider_entity.credentials, authed=True, ) - return {"result": "success"} + return MCPAuthResponse(result="success").model_dump(mode="json") except MCPAuthError as e: try: # Pass the extracted OAuth metadata hints to auth() @@ -1247,7 +1514,7 @@ def post(self, tenant_id: str): with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) response = service.execute_auth_actions(auth_result) - return response + return dump_response(MCPAuthResponse, response) except MCPRefreshTokenError as e: with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) @@ -1270,7 +1537,9 @@ def post(self, tenant_id: str): @console_ns.route("/workspaces/current/tool-provider/mcp/tools/") class ToolMCPDetailApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "MCP provider retrieved successfully", console_ns.models[ToolProviderApiEntityResponse.__name__] + ) @setup_required @login_required @account_initialization_required @@ -1279,28 +1548,33 @@ def get(self, tenant_id: str, provider_id: str): with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) provider = service.get_provider(provider_id=provider_id, tenant_id=tenant_id) - return jsonable_encoder(ToolTransformService.mcp_provider_to_user_provider(provider, for_list=True)) + return _dump_tool_provider_payload( + ToolTransformService.mcp_provider_to_user_provider(provider, for_list=True).to_dict() + ) @console_ns.route("/workspaces/current/tools/mcp") class ToolMCPListAllApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response(200, "MCP tools retrieved successfully", console_ns.models[ToolProviderListResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str): + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) # Skip sensitive data decryption for list view to improve performance tools = service.list_providers(tenant_id=tenant_id, include_sensitive=False) - return [tool.to_dict() for tool in tools] + return _dump_tool_provider_payload_list([tool.to_dict() for tool in tools]) @console_ns.route("/workspaces/current/tool-provider/mcp/update/") class ToolMCPUpdateApi(Resource): - @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "MCP provider tools refreshed successfully", console_ns.models[ToolProviderApiEntityResponse.__name__] + ) @setup_required @login_required @account_initialization_required @@ -1313,20 +1587,15 @@ def get(self, tenant_id: str, provider_id: str): tenant_id=tenant_id, provider_id=provider_id, ) - return jsonable_encoder(tools) + return _dump_tool_provider_payload(tools.to_dict()) @console_ns.route("/mcp/oauth/callback") class ToolMCPCallbackApi(Resource): @console_ns.doc(params=query_params_from_model(MCPCallbackQuery)) - @console_ns.response( - 302, - "Redirect to console OAuth callback page", - console_ns.models[RedirectResponse.__name__], - ) + @console_ns.response(302, "Redirect to OAuth callback page") def get(self): - raw_args = request.args.to_dict() - query = MCPCallbackQuery.model_validate(raw_args) + query = query_params_from_request(MCPCallbackQuery) state_key = query.state authorization_code = query.code @@ -1340,4 +1609,5 @@ def get(self): state_data.provider_id, state_data.tenant_id, tokens.model_dump(), OAuthDataType.TOKENS ) + # response-contract:ignore redirect response return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback") diff --git a/api/controllers/console/workspace/trigger_providers.py b/api/controllers/console/workspace/trigger_providers.py index b960a5eb9c393c..544a6d0212cadb 100644 --- a/api/controllers/console/workspace/trigger_providers.py +++ b/api/controllers/console/workspace/trigger_providers.py @@ -9,14 +9,21 @@ from configs import dify_config from controllers.common.errors import NotFoundError -from controllers.common.fields import BinaryFileResponse, RedirectResponse, SimpleResultResponse +from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models +from core.entities.provider_entities import ProviderConfig from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.oauth import OAuthHandler -from core.trigger.entities.entities import SubscriptionBuilderUpdater +from core.trigger.entities.api_entities import ( + SubscriptionBuilderApiEntity, + TriggerProviderApiEntity, + TriggerProviderSubscriptionApiEntity, +) +from core.trigger.entities.entities import RequestLog, SubscriptionBuilderUpdater from core.trigger.trigger_manager import TriggerManager from extensions.ext_database import db -from graphon.model_runtime.utils.encoders import jsonable_encoder +from fields.base import ResponseModel +from libs.helper import dump_response from libs.login import login_required from models.account import Account from models.provider_ids import TriggerProviderID @@ -51,9 +58,9 @@ class TriggerSubscriptionBuilderVerifyPayload(BaseModel): class TriggerSubscriptionBuilderUpdatePayload(BaseModel): name: str | None = None - parameters: dict[str, Any] | None = Field(default=None) - properties: dict[str, Any] | None = Field(default=None) - credentials: dict[str, Any] | None = Field(default=None) + parameters: dict[str, Any] | None = None + properties: dict[str, Any] | None = None + credentials: dict[str, Any] | None = None @model_validator(mode="after") def check_at_least_one_field(self): @@ -63,28 +70,48 @@ def check_at_least_one_field(self): class TriggerOAuthClientPayload(BaseModel): - client_params: dict[str, Any] | None = Field(default=None) + client_params: dict[str, Any] | None = None enabled: bool | None = None -class TriggerOAuthAuthorizeResponse(BaseModel): +class TriggerProviderListResponse(RootModel[list[TriggerProviderApiEntity]]): + pass + + +class TriggerProviderSubscriptionListResponse(RootModel[list[TriggerProviderSubscriptionApiEntity]]): + pass + + +class TriggerSubscriptionBuilderCreateResponse(ResponseModel): + subscription_builder: SubscriptionBuilderApiEntity + + +class TriggerVerificationResponse(ResponseModel): + verified: bool + + +class TriggerSubscriptionBuilderLogsResponse(ResponseModel): + logs: list[RequestLog] + + +class TriggerOAuthAuthorizeResponse(ResponseModel): authorization_url: str subscription_builder_id: str - subscription_builder: Any + subscription_builder: SubscriptionBuilderApiEntity -class TriggerOAuthClientResponse(BaseModel): +class TriggerOAuthClientResponse(ResponseModel): configured: bool system_configured: bool custom_configured: bool - oauth_client_schema: Any + oauth_client_schema: list[ProviderConfig] custom_enabled: bool redirect_uri: str - params: dict[str, Any] + params: dict[str, Any] = Field(default_factory=dict) -class TriggerProviderOpaqueResponse(RootModel[Any]): - root: Any +class TriggerProviderErrorResponse(ResponseModel): + error: str register_schema_models( @@ -96,18 +123,24 @@ class TriggerProviderOpaqueResponse(RootModel[Any]): ) register_response_schema_models( console_ns, - BinaryFileResponse, - RedirectResponse, SimpleResultResponse, TriggerOAuthAuthorizeResponse, TriggerOAuthClientResponse, - TriggerProviderOpaqueResponse, + TriggerProviderApiEntity, + TriggerProviderErrorResponse, + TriggerProviderListResponse, + TriggerProviderSubscriptionListResponse, + TriggerSubscriptionBuilderCreateResponse, + TriggerSubscriptionBuilderLogsResponse, + SubscriptionBuilderApiEntity, + TriggerVerificationResponse, ) @console_ns.route("/workspaces/current/trigger-provider//icon") class TriggerProviderIconApi(Resource): - @console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__]) + # response-contract:ignore binary trigger provider icon + @console_ns.response(200, "Trigger provider icon") @setup_required @login_required @account_initialization_required @@ -118,31 +151,45 @@ def get(self, tenant_id: str, provider: str): @console_ns.route("/workspaces/current/triggers") class TriggerProviderListApi(Resource): - @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Trigger providers retrieved successfully", + console_ns.models[TriggerProviderListResponse.__name__], + ) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str): """List all trigger providers for the current tenant""" - return jsonable_encoder(TriggerProviderService.list_trigger_providers(tenant_id)) + return dump_response(TriggerProviderListResponse, TriggerProviderService.list_trigger_providers(tenant_id)) @console_ns.route("/workspaces/current/trigger-provider//info") class TriggerProviderInfoApi(Resource): - @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Trigger provider retrieved successfully", + console_ns.models[TriggerProviderApiEntity.__name__], + ) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, provider: str): """Get info for a trigger provider""" - return jsonable_encoder(TriggerProviderService.get_trigger_provider(tenant_id, TriggerProviderID(provider))) + provider_entity = TriggerProviderService.get_trigger_provider(tenant_id, TriggerProviderID(provider)) + return provider_entity.model_dump(mode="json") @console_ns.route("/workspaces/current/trigger-provider//subscriptions/list") class TriggerSubscriptionListApi(Resource): - @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Trigger subscriptions retrieved successfully", + console_ns.models[TriggerProviderSubscriptionListResponse.__name__], + ) + @console_ns.response(404, "Trigger provider not found", console_ns.models[TriggerProviderErrorResponse.__name__]) @setup_required @login_required @edit_permission_required @@ -152,16 +199,18 @@ class TriggerSubscriptionListApi(Resource): @with_current_tenant_id def get(self, tenant_id: str, user: Account, provider: str): """List all trigger subscriptions for the current tenant's provider""" + try: - return jsonable_encoder( + return dump_response( + TriggerProviderSubscriptionListResponse, TriggerProviderService.list_trigger_provider_subscriptions( tenant_id=tenant_id, provider_id=TriggerProviderID(provider), user=user, - ) + ), ) except ValueError as e: - return jsonable_encoder({"error": str(e)}), 404 + return TriggerProviderErrorResponse(error=str(e)).model_dump(mode="json"), 404 except Exception as e: logger.exception("Error listing trigger providers", exc_info=e) raise @@ -172,7 +221,11 @@ def get(self, tenant_id: str, user: Account, provider: str): ) class TriggerSubscriptionBuilderCreateApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderCreatePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Trigger subscription builder created successfully", + console_ns.models[TriggerSubscriptionBuilderCreateResponse.__name__], + ) @setup_required @login_required @edit_permission_required @@ -182,6 +235,7 @@ class TriggerSubscriptionBuilderCreateApi(Resource): @with_current_tenant_id def post(self, tenant_id: str, user: Account, provider: str): """Add a new subscription instance for a trigger provider""" + payload = TriggerSubscriptionBuilderCreatePayload.model_validate(console_ns.payload or {}) try: @@ -192,7 +246,9 @@ def post(self, tenant_id: str, user: Account, provider: str): provider_id=TriggerProviderID(provider), credential_type=credential_type, ) - return jsonable_encoder({"subscription_builder": subscription_builder}) + return TriggerSubscriptionBuilderCreateResponse(subscription_builder=subscription_builder).model_dump( + mode="json" + ) except Exception as e: logger.exception("Error adding provider credential", exc_info=e) raise @@ -202,7 +258,11 @@ def post(self, tenant_id: str, user: Account, provider: str): "/workspaces/current/trigger-provider//subscriptions/builder/", ) class TriggerSubscriptionBuilderGetApi(Resource): - @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Trigger subscription builder retrieved successfully", + console_ns.models[SubscriptionBuilderApiEntity.__name__], + ) @setup_required @login_required @edit_permission_required @@ -210,9 +270,8 @@ class TriggerSubscriptionBuilderGetApi(Resource): @account_initialization_required def get(self, provider: str, subscription_builder_id: str): """Get a subscription instance for a trigger provider""" - return jsonable_encoder( - TriggerSubscriptionBuilderService.get_subscription_builder_by_id(subscription_builder_id) - ) + subscription_builder = TriggerSubscriptionBuilderService.get_subscription_builder_by_id(subscription_builder_id) + return subscription_builder.model_dump(mode="json") @console_ns.route( @@ -220,7 +279,11 @@ def get(self, provider: str, subscription_builder_id: str): ) class TriggerSubscriptionBuilderVerifyApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderVerifyPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Trigger subscription builder verified successfully", + console_ns.models[TriggerVerificationResponse.__name__], + ) @setup_required @login_required @edit_permission_required @@ -230,11 +293,12 @@ class TriggerSubscriptionBuilderVerifyApi(Resource): @with_current_tenant_id def post(self, tenant_id: str, user: Account, provider: str, subscription_builder_id: str): """Verify and update a subscription instance for a trigger provider""" + payload = TriggerSubscriptionBuilderVerifyPayload.model_validate(console_ns.payload or {}) try: # Use atomic update_and_verify to prevent race conditions - return TriggerSubscriptionBuilderService.update_and_verify_builder( + result = TriggerSubscriptionBuilderService.update_and_verify_builder( tenant_id=tenant_id, user_id=user.id, provider_id=TriggerProviderID(provider), @@ -243,6 +307,7 @@ def post(self, tenant_id: str, user: Account, provider: str, subscription_builde credentials=payload.credentials, ), ) + return dump_response(TriggerVerificationResponse, result) except Exception as e: logger.exception("Error verifying provider credential", exc_info=e) raise ValueError(str(e)) from e @@ -253,7 +318,11 @@ def post(self, tenant_id: str, user: Account, provider: str, subscription_builde ) class TriggerSubscriptionBuilderUpdateApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderUpdatePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Trigger subscription builder updated successfully", + console_ns.models[SubscriptionBuilderApiEntity.__name__], + ) @setup_required @login_required @edit_permission_required @@ -262,21 +331,20 @@ class TriggerSubscriptionBuilderUpdateApi(Resource): @with_current_tenant_id def post(self, tenant_id: str, provider: str, subscription_builder_id: str): """Update a subscription instance for a trigger provider""" + payload = TriggerSubscriptionBuilderUpdatePayload.model_validate(console_ns.payload or {}) try: - return jsonable_encoder( - TriggerSubscriptionBuilderService.update_trigger_subscription_builder( - tenant_id=tenant_id, - provider_id=TriggerProviderID(provider), - subscription_builder_id=subscription_builder_id, - subscription_builder_updater=SubscriptionBuilderUpdater( - name=payload.name, - parameters=payload.parameters, - properties=payload.properties, - credentials=payload.credentials, - ), - ) - ) + return TriggerSubscriptionBuilderService.update_trigger_subscription_builder( + tenant_id=tenant_id, + provider_id=TriggerProviderID(provider), + subscription_builder_id=subscription_builder_id, + subscription_builder_updater=SubscriptionBuilderUpdater( + name=payload.name, + parameters=payload.parameters, + properties=payload.properties, + credentials=payload.credentials, + ), + ).model_dump(mode="json") except Exception as e: logger.exception("Error updating provider credential", exc_info=e) raise @@ -286,7 +354,11 @@ def post(self, tenant_id: str, provider: str, subscription_builder_id: str): "/workspaces/current/trigger-provider//subscriptions/builder/logs/", ) class TriggerSubscriptionBuilderLogsApi(Resource): - @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Trigger subscription builder logs retrieved successfully", + console_ns.models[TriggerSubscriptionBuilderLogsResponse.__name__], + ) @setup_required @login_required @edit_permission_required @@ -294,9 +366,10 @@ class TriggerSubscriptionBuilderLogsApi(Resource): @account_initialization_required def get(self, provider: str, subscription_builder_id: str): """Get the request logs for a subscription instance for a trigger provider""" + try: logs = TriggerSubscriptionBuilderService.list_logs(subscription_builder_id) - return jsonable_encoder({"logs": [log.model_dump(mode="json") for log in logs]}) + return dump_response(TriggerSubscriptionBuilderLogsResponse, {"logs": logs}) except Exception as e: logger.exception("Error getting request logs for subscription builder", exc_info=e) raise @@ -307,7 +380,9 @@ def get(self, provider: str, subscription_builder_id: str): ) class TriggerSubscriptionBuilderBuildApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderUpdatePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "Trigger subscription builder built successfully", console_ns.models[SimpleResultResponse.__name__] + ) @setup_required @login_required @edit_permission_required @@ -331,7 +406,7 @@ def post(self, tenant_id: str, user: Account, provider: str, subscription_builde properties=payload.properties, ), ) - return 200 + return SimpleResultResponse(result="success").model_dump(mode="json") except Exception as e: logger.exception("Error building provider credential", exc_info=e) raise ValueError(str(e)) from e @@ -342,7 +417,9 @@ def post(self, tenant_id: str, user: Account, provider: str, subscription_builde ) class TriggerSubscriptionUpdateApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderUpdatePayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, "Trigger subscription updated successfully", console_ns.models[SimpleResultResponse.__name__] + ) @setup_required @login_required @edit_permission_required @@ -351,6 +428,7 @@ class TriggerSubscriptionUpdateApi(Resource): @with_current_tenant_id def post(self, tenant_id: str, subscription_id: str): """Update a subscription instance""" + request = TriggerSubscriptionBuilderUpdatePayload.model_validate(console_ns.payload or {}) subscription = TriggerProviderService.get_subscription_by_id( @@ -376,7 +454,7 @@ def post(self, tenant_id: str, subscription_id: str): name=request.name, properties=request.properties, ) - return 200 + return SimpleResultResponse(result="success").model_dump(mode="json") # For the rest cases(API_KEY, OAUTH2) # we need to call third party provider(e.g. GitHub) to rebuild the subscription @@ -388,7 +466,7 @@ def post(self, tenant_id: str, subscription_id: str): credentials=request.credentials or subscription.credentials, parameters=request.parameters or subscription.parameters, ) - return 200 + return SimpleResultResponse(result="success").model_dump(mode="json") except ValueError as e: raise BadRequest(str(e)) except Exception as e: @@ -409,6 +487,7 @@ class TriggerSubscriptionDeleteApi(Resource): @with_current_tenant_id def post(self, tenant_id: str, subscription_id: str): """Delete a subscription instance""" + try: with sessionmaker(db.engine).begin() as session: # Delete trigger provider subscription @@ -423,7 +502,7 @@ def post(self, tenant_id: str, subscription_id: str): tenant_id=tenant_id, subscription_id=subscription_id, ) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") except ValueError as e: raise BadRequest(str(e)) except Exception as e: @@ -433,9 +512,10 @@ def post(self, tenant_id: str, subscription_id: str): @console_ns.route("/workspaces/current/trigger-provider//subscriptions/oauth/authorize") class TriggerOAuthAuthorizeApi(Resource): + # response-contract:ignore cookie-bearing Flask response @console_ns.response( 200, - "Authorization URL retrieved successfully", + "Trigger OAuth authorization URL generated successfully", console_ns.models[TriggerOAuthAuthorizeResponse.__name__], ) @setup_required @@ -445,10 +525,12 @@ class TriggerOAuthAuthorizeApi(Resource): @with_current_tenant_id def get(self, tenant_id: str, user: Account, provider: str): """Initiate OAuth authorization flow for a trigger provider""" + try: provider_id = TriggerProviderID(provider) plugin_id = provider_id.plugin_id provider_name = provider_id.provider_name + tenant_id = tenant_id # Get OAuth client configuration oauth_client_params = TriggerProviderService.get_oauth_client( @@ -492,15 +574,12 @@ def get(self, tenant_id: str, user: Account, provider: str): system_credentials=oauth_client_params, ) - # Create response with cookie response = make_response( - jsonable_encoder( - { - "authorization_url": authorization_url_response.authorization_url, - "subscription_builder_id": subscription_builder.id, - "subscription_builder": subscription_builder, - } - ) + TriggerOAuthAuthorizeResponse( + authorization_url=authorization_url_response.authorization_url, + subscription_builder_id=subscription_builder.id, + subscription_builder=subscription_builder, + ).model_dump(mode="json") ) response.set_cookie( "context_id", @@ -519,11 +598,8 @@ def get(self, tenant_id: str, user: Account, provider: str): @console_ns.route("/oauth/plugin//trigger/callback") class TriggerOAuthCallbackApi(Resource): - @console_ns.response( - 302, - "Redirect to console OAuth callback page", - console_ns.models[RedirectResponse.__name__], - ) + # response-contract:ignore redirect response + @console_ns.response(302, "Redirect to OAuth callback page") @setup_required def get(self, provider: str): """Handle OAuth callback for trigger provider""" @@ -589,7 +665,11 @@ def get(self, provider: str): @console_ns.route("/workspaces/current/trigger-provider//oauth/client") class TriggerOAuthClientManageApi(Resource): - @console_ns.response(200, "Success", console_ns.models[TriggerOAuthClientResponse.__name__]) + @console_ns.response( + 200, + "Trigger OAuth client retrieved successfully", + console_ns.models[TriggerOAuthClientResponse.__name__], + ) @setup_required @login_required @is_admin_or_owner_required @@ -598,6 +678,7 @@ class TriggerOAuthClientManageApi(Resource): @with_current_tenant_id def get(self, tenant_id: str, provider: str): """Get OAuth client configuration for a provider""" + try: provider_id = TriggerProviderID(provider) @@ -618,24 +699,24 @@ def get(self, tenant_id: str, provider: str): ) provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/trigger/callback" - return jsonable_encoder( - { - "configured": bool(custom_params or system_client_exists), - "system_configured": system_client_exists, - "custom_configured": bool(custom_params), - "oauth_client_schema": provider_controller.get_oauth_client_schema(), - "custom_enabled": is_custom_enabled, - "redirect_uri": redirect_uri, - "params": custom_params or {}, - } - ) + return TriggerOAuthClientResponse( + configured=bool(custom_params or system_client_exists), + system_configured=system_client_exists, + custom_configured=bool(custom_params), + oauth_client_schema=provider_controller.get_oauth_client_schema(), + custom_enabled=is_custom_enabled, + redirect_uri=redirect_uri, + params=dict(custom_params), + ).model_dump(mode="json") except Exception as e: logger.exception("Error getting OAuth client", exc_info=e) raise @console_ns.expect(console_ns.models[TriggerOAuthClientPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response( + 200, "Trigger OAuth client saved successfully", console_ns.models[SimpleResultResponse.__name__] + ) @setup_required @login_required @is_admin_or_owner_required @@ -644,16 +725,18 @@ def get(self, tenant_id: str, provider: str): @with_current_tenant_id def post(self, tenant_id: str, provider: str): """Configure custom OAuth client for a provider""" + payload = TriggerOAuthClientPayload.model_validate(console_ns.payload or {}) try: provider_id = TriggerProviderID(provider) - return TriggerProviderService.save_custom_oauth_client_params( + result = TriggerProviderService.save_custom_oauth_client_params( tenant_id=tenant_id, provider_id=provider_id, client_params=payload.client_params, enabled=payload.enabled, ) + return dump_response(SimpleResultResponse, result) except ValueError as e: raise BadRequest(str(e)) @@ -661,22 +744,26 @@ def post(self, tenant_id: str, provider: str): logger.exception("Error configuring OAuth client", exc_info=e) raise + @console_ns.response( + 200, "Trigger OAuth client deleted successfully", console_ns.models[SimpleResultResponse.__name__] + ) @setup_required @login_required @is_admin_or_owner_required @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required - @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @with_current_tenant_id def delete(self, tenant_id: str, provider: str): """Remove custom OAuth client configuration""" + try: provider_id = TriggerProviderID(provider) - return TriggerProviderService.delete_custom_oauth_client_params( + result = TriggerProviderService.delete_custom_oauth_client_params( tenant_id=tenant_id, provider_id=provider_id, ) + return dump_response(SimpleResultResponse, result) except ValueError as e: raise BadRequest(str(e)) except Exception as e: @@ -689,7 +776,11 @@ def delete(self, tenant_id: str, provider: str): ) class TriggerSubscriptionVerifyApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderVerifyPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) + @console_ns.response( + 200, + "Trigger subscription verified successfully", + console_ns.models[TriggerVerificationResponse.__name__], + ) @setup_required @login_required @edit_permission_required @@ -699,6 +790,7 @@ class TriggerSubscriptionVerifyApi(Resource): @with_current_tenant_id def post(self, tenant_id: str, user: Account, provider: str, subscription_id: str): """Verify credentials for an existing subscription (edit mode only)""" + verify_request = TriggerSubscriptionBuilderVerifyPayload.model_validate(console_ns.payload or {}) try: @@ -709,7 +801,7 @@ def post(self, tenant_id: str, user: Account, provider: str, subscription_id: st subscription_id=subscription_id, credentials=verify_request.credentials, ) - return result + return dump_response(TriggerVerificationResponse, result) except ValueError as e: logger.warning("Credential verification failed", exc_info=e) raise BadRequest(str(e)) from e diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py index 0afd7e06bf7d62..2644229d3e6810 100644 --- a/api/controllers/console/workspace/workspace.py +++ b/api/controllers/console/workspace/workspace.py @@ -2,7 +2,7 @@ from datetime import datetime from flask import request -from flask_restx import Resource, fields, marshal +from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from werkzeug.exceptions import Unauthorized @@ -16,7 +16,12 @@ TooManyFilesError, UnsupportedFileTypeError, ) -from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.common.schema import ( + query_params_from_model, + query_params_from_request, + register_response_schema_models, + register_schema_models, +) from controllers.console import console_ns from controllers.console.admin import admin_required from controllers.console.error import AccountNotLinkTenantError @@ -31,7 +36,7 @@ from enums.cloud_plan import CloudPlan from extensions.ext_database import db from fields.base import ResponseModel -from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp +from libs.helper import dump_response, to_timestamp from libs.login import login_required from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus from services.account_service import TenantService @@ -102,6 +107,7 @@ class TenantListItemResponse(ResponseModel): plan: str | None = None status: str | None = None created_at: int | None = None + last_opened_at: int | None = None current: bool @field_validator("plan", "status", mode="before") @@ -113,9 +119,9 @@ def _normalize_enum_like(cls, value): return value return str(getattr(value, "value", value)) - @field_validator("created_at", mode="before") + @field_validator("created_at", "last_opened_at", mode="before") @classmethod - def _normalize_created_at(cls, value: datetime | int | None): + def _normalize_timestamp(cls, value: datetime | int | None): return to_timestamp(value) @@ -131,7 +137,7 @@ class WorkspaceListItemResponse(ResponseModel): @field_validator("status", mode="before") @classmethod - def _normalize_status(cls, value): + def _normalize_enum_like(cls, value): if value is None: return None if isinstance(value, str): @@ -144,7 +150,7 @@ def _normalize_created_at(cls, value: datetime | int | None): return to_timestamp(value) -class WorkspaceListResponse(ResponseModel): +class WorkspacePaginationResponse(ResponseModel): data: list[WorkspaceListItemResponse] has_more: bool limit: int @@ -157,7 +163,7 @@ class SwitchWorkspaceResponse(ResponseModel): new_tenant: TenantInfoResponse -class WorkspaceMutationResponse(ResponseModel): +class WorkspaceTenantResultResponse(ResponseModel): result: str tenant: TenantInfoResponse @@ -172,6 +178,16 @@ class WorkspacePermissionResponse(ResponseModel): allow_owner_transfer: bool +WORKSPACE_LOGO_UPLOAD_PARAMS = { + "file": { + "in": "formData", + "type": "file", + "required": True, + "description": "Workspace web app logo file. Only SVG and PNG files are supported.", + } +} + + register_schema_models( console_ns, WorkspaceListQuery, @@ -182,49 +198,17 @@ class WorkspacePermissionResponse(ResponseModel): register_response_schema_models( console_ns, TenantInfoResponse, + TenantListItemResponse, TenantListResponse, - WorkspaceListResponse, + WorkspaceCustomConfigResponse, + WorkspaceListItemResponse, + WorkspacePaginationResponse, SwitchWorkspaceResponse, - WorkspaceMutationResponse, + WorkspaceTenantResultResponse, WorkspaceLogoUploadResponse, - WorkspaceCustomConfigResponse, WorkspacePermissionResponse, ) -provider_fields = { - "provider_name": fields.String, - "provider_type": fields.String, - "is_valid": fields.Boolean, - "token_is_set": fields.Boolean, -} - -tenant_fields = { - "id": fields.String, - "name": fields.String, - "plan": fields.String, - "status": fields.String, - "created_at": TimestampField, - "role": fields.String, - "in_trial": fields.Boolean, - "trial_end_reason": fields.String, - "custom_config": fields.Raw(attribute="custom_config"), - "trial_credits": fields.Integer, - "trial_credits_used": fields.Integer, - "next_credit_reset_date": fields.Integer, -} - -tenants_fields = { - "id": fields.String, - "name": fields.String, - "plan": fields.String, - "status": fields.String, - "created_at": TimestampField, - "last_opened_at": OptionalTimestampField, - "current": fields.Boolean, -} - -workspace_fields = {"id": fields.String, "name": fields.String, "status": fields.String, "created_at": TimestampField} - @console_ns.route("/workspaces") class TenantListApi(Resource): @@ -279,18 +263,17 @@ def get(self, current_tenant_id: str, current_user: Account): tenant_dicts.append(tenant_dict) - return {"workspaces": marshal(tenant_dicts, tenants_fields)}, 200 + return dump_response(TenantListResponse, {"workspaces": tenant_dicts}), 200 @console_ns.route("/all-workspaces") class WorkspaceListApi(Resource): @console_ns.doc(params=query_params_from_model(WorkspaceListQuery)) - @console_ns.response(200, "Success", console_ns.models[WorkspaceListResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[WorkspacePaginationResponse.__name__]) @setup_required @admin_required def get(self): - payload = request.args.to_dict(flat=True) - args = WorkspaceListQuery.model_validate(payload) + args = query_params_from_request(WorkspaceListQuery) stmt = select(Tenant).order_by(Tenant.created_at.desc()) tenants = db.paginate(select=stmt, page=args.page, per_page=args.limit, error_out=False) @@ -299,13 +282,9 @@ def get(self): if tenants.has_next: has_more = True - return { - "data": marshal(tenants.items, workspace_fields), - "has_more": has_more, - "limit": args.limit, - "page": args.page, - "total": tenants.total, - }, 200 + return WorkspacePaginationResponse( + data=tenants.items, has_more=has_more, limit=args.limit, page=args.page, total=tenants.total or 0 + ).model_dump(mode="json"), 200 @console_ns.route("/workspaces/current", endpoint="workspaces_current") @@ -359,13 +338,15 @@ def post(self, current_user: Account): if new_tenant is None: raise ValueError("Tenant not found") - return {"result": "success", "new_tenant": marshal(WorkspaceService.get_tenant_info(new_tenant), tenant_fields)} + return SwitchWorkspaceResponse( + result="success", new_tenant=WorkspaceService.get_tenant_info(new_tenant) + ).model_dump(mode="json") @console_ns.route("/workspaces/custom-config") class CustomConfigWorkspaceApi(Resource): @console_ns.expect(console_ns.models[WorkspaceCustomConfigPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[WorkspaceMutationResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -388,11 +369,14 @@ def post(self, current_tenant_id: str): tenant.custom_config_dict = custom_config_dict db.session.commit() - return {"result": "success", "tenant": marshal(WorkspaceService.get_tenant_info(tenant), tenant_fields)} + return WorkspaceTenantResultResponse( + result="success", tenant=WorkspaceService.get_tenant_info(tenant) + ).model_dump(mode="json") @console_ns.route("/workspaces/custom-config/webapp-logo/upload") class WebappLogoWorkspaceApi(Resource): + @console_ns.doc(consumes=["multipart/form-data"], params=WORKSPACE_LOGO_UPLOAD_PARAMS) @console_ns.response(201, "Logo uploaded", console_ns.models[WorkspaceLogoUploadResponse.__name__]) @setup_required @login_required @@ -429,13 +413,13 @@ def post(self, current_user: Account): except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError() - return {"id": upload_file.id}, 201 + return WorkspaceLogoUploadResponse(id=upload_file.id).model_dump(mode="json"), 201 @console_ns.route("/workspaces/info") class WorkspaceInfoApi(Resource): @console_ns.expect(console_ns.models[WorkspaceInfoPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[WorkspaceMutationResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -451,7 +435,9 @@ def post(self, current_tenant_id: str): tenant.name = args.name db.session.commit() - return {"result": "success", "tenant": marshal(WorkspaceService.get_tenant_info(tenant), tenant_fields)} + return WorkspaceTenantResultResponse( + result="success", tenant=WorkspaceService.get_tenant_info(tenant) + ).model_dump(mode="json") @console_ns.route("/workspaces/current/permission") @@ -475,8 +461,8 @@ def get(self, current_tenant_id: str): # Get workspace permissions from enterprise service permission = EnterpriseService.WorkspacePermissionService.get_permission(current_tenant_id) - return { - "workspace_id": permission.workspace_id, - "allow_member_invite": permission.allow_member_invite, - "allow_owner_transfer": permission.allow_owner_transfer, - }, 200 + return WorkspacePermissionResponse( + workspace_id=permission.workspace_id, + allow_member_invite=permission.allow_member_invite, + allow_owner_transfer=permission.allow_owner_transfer, + ).model_dump(mode="json"), 200 diff --git a/api/controllers/mcp/mcp.py b/api/controllers/mcp/mcp.py index cda6b915018060..46f9ab644b5c64 100644 --- a/api/controllers/mcp/mcp.py +++ b/api/controllers/mcp/mcp.py @@ -2,11 +2,11 @@ from flask import Response from flask_restx import Resource -from pydantic import BaseModel, Field, ValidationError +from pydantic import BaseModel, Field, RootModel, ValidationError from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker -from controllers.common.schema import register_schema_model +from controllers.common.schema import register_response_schema_models, register_schema_model from controllers.mcp import mcp_ns from core.mcp import types as mcp_types from core.mcp.server.streamable_http import handle_mcp_request @@ -33,7 +33,12 @@ class MCPRequestPayload(BaseModel): id: int | str | None = Field(default=None, description="Request ID for tracking responses") +class MCPJSONRPCResponse(RootModel[mcp_types.JSONRPCResponse | mcp_types.JSONRPCError]): + pass + + register_schema_model(mcp_ns, MCPRequestPayload) +register_response_schema_models(mcp_ns, MCPJSONRPCResponse) @mcp_ns.route("/server//mcp") @@ -42,13 +47,10 @@ class MCPAppApi(Resource): @mcp_ns.doc("handle_mcp_request") @mcp_ns.doc(description="Handle Model Context Protocol (MCP) requests for a specific server") @mcp_ns.doc(params={"server_code": "Unique identifier for the MCP server"}) - @mcp_ns.doc( - responses={ - 200: "MCP response successfully processed", - 400: "Invalid MCP request or parameters", - 404: "Server or app not found", - } - ) + @mcp_ns.response(200, "MCP JSON-RPC response", mcp_ns.models[MCPJSONRPCResponse.__name__]) + @mcp_ns.response(202, "MCP notification accepted") + @mcp_ns.response(400, "Invalid MCP request or parameters") + @mcp_ns.response(404, "Server or app not found") def post(self, server_code: str): """Handle MCP requests for a specific server. @@ -64,6 +66,7 @@ def post(self, server_code: str): Raises: ValidationError: Invalid request format or parameters """ + # response-contract:ignore MCP route returns Flask Response from JSON-RPC handler args = MCPRequestPayload.model_validate(mcp_ns.payload or {}) request_id: Union[int, str] | None = args.id mcp_request = self._parse_mcp_request(args.model_dump(exclude_none=True)) diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index c11019cf62796f..81c65ca03bec34 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -20,7 +20,7 @@ # Register response/query models BEFORE importing controller modules so that # @openapi_ns.response / @openapi_ns.expect decorators can resolve model names. -from controllers.common.fields import EventStreamResponse +from controllers.common.fields import EventStreamResponse, SimpleResultResponse from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models from controllers.openapi._models import ( AccountPayload, @@ -95,6 +95,7 @@ openapi_ns, ErrorBody, EventStreamResponse, + SimpleResultResponse, UsageInfo, MessageMetadata, AppListRow, diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index 7e77e3aa747ef8..a22534ae82c6c2 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from collections.abc import Callable, Iterator +from collections.abc import Callable, Generator from contextlib import contextmanager from typing import Any @@ -61,7 +61,7 @@ @contextmanager -def _translate_service_errors() -> Iterator[None]: +def _translate_service_errors() -> Generator[None, None, None]: try: yield except WorkflowNotFoundError as ex: @@ -166,6 +166,7 @@ def post(self, app_id: str, *, auth_data: AuthData, body: AppRunRequest): surface="apps", ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(stream_obj) diff --git a/api/controllers/service_api/app/annotation.py b/api/controllers/service_api/app/annotation.py index 8a57ec9818ad06..947b252d748931 100644 --- a/api/controllers/service_api/app/annotation.py +++ b/api/controllers/service_api/app/annotation.py @@ -12,8 +12,13 @@ from controllers.service_api.wraps import validate_app_token from extensions.ext_database import db from extensions.ext_redis import redis_client -from fields.annotation_fields import Annotation, AnnotationList -from fields.base import ResponseModel +from fields.annotation_fields import ( + Annotation, + AnnotationJobStatusDetailResponse, + AnnotationJobStatusResponse, + AnnotationList, +) +from libs.helper import dump_response from models.model import App from services.annotation_service import ( AppAnnotationService, @@ -45,12 +50,6 @@ class AnnotationListQuery(BaseModel): keyword: str = Field(default="", description="Keyword to filter annotations by question or answer content.") -class AnnotationJobStatusResponse(ResponseModel): - job_id: str - job_status: str - error_msg: str | None = None - - ANNOTATION_REPLY_ACTION_PARAM = { "description": "Action to perform: `enable` or `disable`.", "enum": ["enable", "disable"], @@ -66,7 +65,13 @@ class AnnotationJobStatusResponse(ResponseModel): Annotation, AnnotationList, ) -register_response_schema_models(service_api_ns, AnnotationJobStatusResponse) +register_response_schema_models( + service_api_ns, + Annotation, + AnnotationList, + AnnotationJobStatusResponse, + AnnotationJobStatusDetailResponse, +) @service_api_ns.route("/apps/annotation-reply/") @@ -112,7 +117,7 @@ def post(self, app_model: App, action: Literal["enable", "disable"]): result = AppAnnotationService.enable_app_annotation(enable_args, app_model.id) case "disable": result = AppAnnotationService.disable_app_annotation(app_model.id) - return result, 200 + return dump_response(AnnotationJobStatusResponse, result), 200 @service_api_ns.route("/apps/annotation-reply//status/") @@ -150,7 +155,7 @@ class AnnotationReplyActionStatusApi(Resource): @service_api_ns.response( 200, "Job status retrieved successfully", - service_api_ns.models[AnnotationJobStatusResponse.__name__], + service_api_ns.models[AnnotationJobStatusDetailResponse.__name__], ) @validate_app_token def get(self, app_model: App, job_id: UUID, action: str): @@ -167,7 +172,9 @@ def get(self, app_model: App, job_id: UUID, action: str): app_annotation_error_key = f"{action}_app_annotation_error_{job_id_str}" error_msg = redis_client.get(app_annotation_error_key).decode() - return {"job_id": job_id_str, "job_status": job_status, "error_msg": error_msg}, 200 + return AnnotationJobStatusDetailResponse( + job_id=job_id_str, job_status=job_status, error_msg=error_msg + ).model_dump(mode="json"), 200 @service_api_ns.route("/apps/annotations") @@ -203,14 +210,13 @@ def get(self, app_model: App): app_model.id, query.page, query.limit, query.keyword ) annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True) - response = AnnotationList( + return AnnotationList( data=annotation_models, has_more=len(annotation_list) == query.limit, limit=query.limit, total=total, page=query.page, - ) - return response.model_dump(mode="json") + ).model_dump(mode="json") @service_api_ns.doc( summary="Create Annotation", @@ -243,8 +249,7 @@ def post(self, app_model: App): payload = AnnotationCreatePayload.model_validate(service_api_ns.payload or {}) insert_args: InsertAnnotationArgs = {"question": payload.question, "answer": payload.answer} annotation = AppAnnotationService.insert_app_annotation_directly(insert_args, app_model.id) - response = Annotation.model_validate(annotation, from_attributes=True) - return response.model_dump(mode="json"), HTTPStatus.CREATED + return dump_response(Annotation, annotation), HTTPStatus.CREATED @service_api_ns.route("/apps/annotations/") @@ -285,8 +290,7 @@ def put(self, app_model: App, annotation_id: UUID): annotation = AppAnnotationService.update_app_annotation_directly( update_args, app_model.id, str(annotation_id), db.session ) - response = Annotation.model_validate(annotation, from_attributes=True) - return response.model_dump(mode="json") + return dump_response(Annotation, annotation) @service_api_ns.doc( summary="Delete Annotation", diff --git a/api/controllers/service_api/app/audio.py b/api/controllers/service_api/app/audio.py index 59ed4b4a4b1b3e..1ec0a372d4de34 100644 --- a/api/controllers/service_api/app/audio.py +++ b/api/controllers/service_api/app/audio.py @@ -25,6 +25,7 @@ from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from extensions.ext_database import db from graphon.model_runtime.errors.invoke import InvokeError +from libs.helper import dump_response from models.model import App, EndUser from services.audio_service import AudioService from services.errors.audio import ( @@ -101,7 +102,7 @@ def post(self, app_model: App, end_user: EndUser): try: response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=end_user.id) - return response + return dump_response(AudioTranscriptResponse, response) except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") raise AppUnavailableError() @@ -164,6 +165,7 @@ class TextApi(Resource): 500: "Internal server error", } ) + # TTS returns provider audio bytes, so the success response is intentionally schema-less. @service_api_ns.response(200, "Text successfully converted to audio") @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) def post(self, app_model: App, end_user: EndUser): @@ -177,7 +179,7 @@ def post(self, app_model: App, end_user: EndUser): message_id = payload.message_id text = payload.text voice = payload.voice - response = AudioService.transcript_tts( + return AudioService.transcript_tts( app_model=app_model, session=db.session, text=text, @@ -185,8 +187,6 @@ def post(self, app_model: App, end_user: EndUser): end_user=end_user.external_user_id, message_id=message_id, ) - - return response except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") raise AppUnavailableError() diff --git a/api/controllers/service_api/app/completion.py b/api/controllers/service_api/app/completion.py index 1468f3d776fcd1..873911ec50eb2d 100644 --- a/api/controllers/service_api/app/completion.py +++ b/api/controllers/service_api/app/completion.py @@ -9,7 +9,7 @@ from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services -from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse +from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.app.error import ( @@ -154,7 +154,7 @@ def normalize_conversation_id(cls, value: str | UUID | None) -> str | None: register_schema_models(service_api_ns, CompletionRequestPayload, ChatRequestPayload) -register_response_schema_models(service_api_ns, GeneratedAppResponse, SimpleResultResponse) +register_response_schema_models(service_api_ns, SimpleResultResponse) @service_api_ns.route("/completion-messages") @@ -197,11 +197,7 @@ class CompletionApi(Resource): 500: "Internal server error", } ) - @service_api_ns.response( - 200, - "Completion created successfully", - service_api_ns.models[GeneratedAppResponse.__name__], - ) + @service_api_ns.response(200, "Completion created successfully") @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) def post(self, app_model: App, end_user: EndUser): """Create a completion for the given prompt. @@ -236,6 +232,7 @@ def post(self, app_model: App, end_user: EndUser): streaming=streaming, ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -296,7 +293,7 @@ def post(self, app_model: App, end_user: EndUser, task_id: str): app_mode=AppMode.value_of(app_model.mode), ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @service_api_ns.route("/chat-messages") @@ -346,11 +343,7 @@ class ChatApi(Resource): 500: "Internal server error", } ) - @service_api_ns.response( - 200, - "Message sent successfully", - service_api_ns.models[GeneratedAppResponse.__name__], - ) + @service_api_ns.response(200, "Message sent successfully") @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) def post(self, app_model: App, end_user: EndUser): """Send a message in a chat conversation. @@ -379,6 +372,7 @@ def post(self, app_model: App, end_user: EndUser): app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=streaming ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except WorkflowNotFoundError as ex: raise NotFound(str(ex)) @@ -448,4 +442,4 @@ def post(self, app_model: App, end_user: EndUser, task_id: str): app_mode=app_mode, ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index 9b5533ea07a9df..73153ec72bd888 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -24,7 +24,7 @@ SimpleConversation, ) from graphon.variables.types import SegmentType -from libs.helper import UUIDStrOrEmpty, to_timestamp +from libs.helper import UUIDStrOrEmpty, dump_response, to_timestamp from models.model import App, AppMode, EndUser from services.conversation_service import ConversationService @@ -142,15 +142,13 @@ class ConversationVariableInfiniteScrollPaginationResponse(ResponseModel): ConversationRenamePayload, ConversationVariablesQuery, ConversationVariableUpdatePayload, - ConversationVariableResponse, - ConversationVariableInfiniteScrollPaginationResponse, ) register_response_schema_models( service_api_ns, - ConversationInfiniteScrollPagination, - SimpleConversation, ConversationVariableResponse, ConversationVariableInfiniteScrollPaginationResponse, + ConversationInfiniteScrollPagination, + SimpleConversation, ) @@ -166,9 +164,9 @@ class ConversationApi(Resource): 404: "`not_found` : Last conversation does not exist (invalid `last_id`).", }, ) - @service_api_ns.doc(params=query_params_from_model(ConversationListQuery)) @service_api_ns.doc("list_conversations") @service_api_ns.doc(description="List all conversations for the current user") + @service_api_ns.doc(params=query_params_from_model(ConversationListQuery)) @service_api_ns.doc( responses={ 200: "Conversations retrieved successfully", @@ -192,7 +190,7 @@ def get(self, app_model: App, end_user: EndUser): raise NotChatAppError() query_args = ConversationListQuery.model_validate(request.args.to_dict()) - last_id = str(query_args.last_id) if query_args.last_id else None + last_id = query_args.last_id or None try: with sessionmaker(db.engine).begin() as session: @@ -208,9 +206,7 @@ def get(self, app_model: App, end_user: EndUser): adapter = TypeAdapter(SimpleConversation) conversations = [adapter.validate_python(item, from_attributes=True) for item in pagination.data] return ConversationInfiniteScrollPagination( - limit=pagination.limit, - has_more=pagination.has_more, - data=conversations, + limit=pagination.limit, has_more=pagination.has_more, data=conversations ).model_dump(mode="json") except services.errors.conversation.LastConversationNotExistsError: raise NotFound("Last Conversation Not Exists.") @@ -301,11 +297,7 @@ def post(self, app_model: App, end_user: EndUser, c_id: UUID): conversation = ConversationService.rename( app_model, conversation_id, end_user, payload.name, payload.auto_generate ) - return ( - TypeAdapter(SimpleConversation) - .validate_python(conversation, from_attributes=True) - .model_dump(mode="json") - ) + return dump_response(SimpleConversation, conversation) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -322,10 +314,9 @@ class ConversationVariablesApi(Resource): 404: "`not_found` : Conversation does not exist.", }, ) - @service_api_ns.doc(params=query_params_from_model(ConversationVariablesQuery)) @service_api_ns.doc("list_conversation_variables") @service_api_ns.doc(description="List all variables for a conversation") - @service_api_ns.doc(params={"c_id": "Conversation ID."}) + @service_api_ns.doc(params={"c_id": "Conversation ID.", **query_params_from_model(ConversationVariablesQuery)}) @service_api_ns.doc( responses={ 200: "Variables retrieved successfully", @@ -352,15 +343,13 @@ def get(self, app_model: App, end_user: EndUser, c_id: UUID): conversation_id = str(c_id) query_args = ConversationVariablesQuery.model_validate(request.args.to_dict()) - last_id = str(query_args.last_id) if query_args.last_id else None + last_id = query_args.last_id or None try: pagination = ConversationService.get_conversational_variable( app_model, conversation_id, end_user, query_args.limit, last_id, query_args.variable_name ) - return ConversationVariableInfiniteScrollPaginationResponse.model_validate( - pagination, from_attributes=True - ).model_dump(mode="json") + return dump_response(ConversationVariableInfiniteScrollPaginationResponse, pagination) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -419,7 +408,7 @@ def put(self, app_model: App, end_user: EndUser, c_id: UUID, variable_id: UUID): variable = ConversationService.update_conversation_variable( app_model, conversation_id, variable_id_str, end_user, payload.value ) - return ConversationVariableResponse.model_validate(variable, from_attributes=True).model_dump(mode="json") + return dump_response(ConversationVariableResponse, variable) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") except services.errors.conversation.ConversationVariableNotExistsError: diff --git a/api/controllers/service_api/app/file.py b/api/controllers/service_api/app/file.py index 9210c60adeb3f6..aff2e0c11634dd 100644 --- a/api/controllers/service_api/app/file.py +++ b/api/controllers/service_api/app/file.py @@ -16,6 +16,7 @@ from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from extensions.ext_database import db from fields.file_fields import FileResponse +from libs.helper import dump_response from models import App, EndUser from services.file_service import FileService @@ -87,5 +88,4 @@ def post(self, app_model: App, end_user: EndUser): except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError() - response = FileResponse.model_validate(upload_file, from_attributes=True) - return response.model_dump(mode="json"), 201 + return dump_response(FileResponse, upload_file), 201 diff --git a/api/controllers/service_api/app/message.py b/api/controllers/service_api/app/message.py index 18d1c5d32547d0..2b1a8230efb620 100644 --- a/api/controllers/service_api/app/message.py +++ b/api/controllers/service_api/app/message.py @@ -59,12 +59,14 @@ class AppFeedbackListResponse(ResponseModel): ResultResponse, SimpleResultStringListResponse, MessageInfiniteScrollPagination, + MessageListItem, AppFeedbackListResponse, ) @service_api_ns.route("/messages") class MessageListApi(Resource): + @service_api_ns.doc("list_messages") @service_api_ns.doc( summary="List Conversation Messages", description=( @@ -75,15 +77,15 @@ class MessageListApi(Resource): responses={ 200: "Successfully retrieved conversation history.", 400: "`not_chat_app` : App mode does not match the API route.", - 404: ("- `not_found` : Conversation does not exist.\n- `not_found` : First message does not exist."), + 404: "- `not_found` : Conversation does not exist.\n- `not_found` : First message does not exist.", }, ) @service_api_ns.doc(params=query_params_from_model(MessageListQuery)) - @service_api_ns.doc("list_messages") @service_api_ns.doc(description="List messages in a conversation") @service_api_ns.doc( responses={ 200: "Messages retrieved successfully", + 400: "`not_chat_app` : App mode does not match the API route.", 401: "Unauthorized - invalid API token", 404: "Conversation or first message not found", } @@ -104,8 +106,8 @@ def get(self, app_model: App, end_user: EndUser): raise NotChatAppError() query_args = MessageListQuery.model_validate(request.args.to_dict()) - conversation_id = str(query_args.conversation_id) - first_id = str(query_args.first_id) if query_args.first_id else None + conversation_id = query_args.conversation_id + first_id = query_args.first_id or None try: pagination = MessageService.pagination_by_first_id( @@ -114,9 +116,7 @@ def get(self, app_model: App, end_user: EndUser): adapter = TypeAdapter(MessageListItem) items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data] return MessageInfiniteScrollPagination( - limit=pagination.limit, - has_more=pagination.has_more, - data=items, + limit=pagination.limit, has_more=pagination.has_more, data=items ).model_dump(mode="json") except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -126,21 +126,20 @@ def get(self, app_model: App, end_user: EndUser): @service_api_ns.route("/messages//feedbacks") class MessageFeedbackApi(Resource): + @expect_with_user(service_api_ns, MessageFeedbackPayload) + @service_api_ns.response(200, "Feedback submitted successfully", service_api_ns.models[ResultResponse.__name__]) + @service_api_ns.doc("create_message_feedback") @service_api_ns.doc( summary="Submit Message Feedback", description=( "Submit feedback for a message. End users can rate messages as `like` or `dislike`, and " - "optionally provide text feedback. Pass `null` for `rating` to revoke previously submitted " - "feedback." + "optionally provide text feedback. Pass `null` for `rating` to revoke previously submitted feedback." ), tags=["Feedback"], responses={ 404: "`not_found` : Message does not exist.", }, ) - @expect_with_user(service_api_ns, MessageFeedbackPayload) - @service_api_ns.response(200, "Feedback submitted successfully", service_api_ns.models[ResultResponse.__name__]) - @service_api_ns.doc("create_message_feedback") @service_api_ns.doc(description="Submit feedback for a message") @service_api_ns.doc(params={"message_id": "Message ID."}) @service_api_ns.doc( @@ -176,11 +175,12 @@ def post(self, app_model: App, end_user: EndUser, message_id: UUID): @service_api_ns.route("/app/feedbacks") class AppGetFeedbacksApi(Resource): + @service_api_ns.doc("get_app_feedbacks") @service_api_ns.doc( summary="List App Feedbacks", description=( - "Retrieve a paginated list of all feedback submitted for messages in this application, " - "including both end-user and admin feedback." + "Retrieve a paginated list of all feedback submitted for messages in this application, including both " + "end-user and admin feedback." ), tags=["Feedback"], responses={ @@ -188,7 +188,6 @@ class AppGetFeedbacksApi(Resource): }, ) @service_api_ns.doc(params=query_params_from_model(FeedbackListQuery)) - @service_api_ns.doc("get_app_feedbacks") @service_api_ns.doc(description="Get all feedbacks for the application") @service_api_ns.doc( responses={ @@ -209,11 +208,12 @@ def get(self, app_model: App): """ query_args = FeedbackListQuery.model_validate(request.args.to_dict()) feedbacks = MessageService.get_all_messages_feedbacks(app_model, page=query_args.page, limit=query_args.limit) - return {"data": feedbacks} + return AppFeedbackListResponse(data=feedbacks).model_dump(mode="json") @service_api_ns.route("/messages//suggested") class MessageSuggestedApi(Resource): + @service_api_ns.doc("get_suggested_questions") @service_api_ns.doc( summary="Get Next Suggested Questions", description="Get next questions suggestions for the current message.", @@ -233,7 +233,6 @@ class MessageSuggestedApi(Resource): "Suggested questions retrieved successfully", service_api_ns.models[SimpleResultStringListResponse.__name__], ) - @service_api_ns.doc("get_suggested_questions") @service_api_ns.doc(description="Get suggested follow-up questions for a message") @service_api_ns.doc(params={"message_id": "Message ID"}) @service_api_ns.doc( @@ -268,4 +267,4 @@ def get(self, app_model: App, end_user: EndUser, message_id: UUID): logger.exception("internal server error.") raise InternalServerError() - return {"result": "success", "data": questions} + return SimpleResultStringListResponse(result="success", data=questions).model_dump(mode="json") diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index 091b79fefbdb02..d685172d6d2685 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -1,19 +1,24 @@ import logging from collections.abc import Mapping from datetime import datetime -from typing import Literal, override +from typing import Literal from dateutil.parser import isoparse from flask import request -from flask_restx import Resource, fields -from pydantic import BaseModel, Field, field_validator +from flask_restx import Resource +from pydantic import BaseModel, Field, field_validator, model_validator from pydantic.json_schema import SkipJsonSchema from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from controllers.common.controller_schemas import WorkflowRunPayload as WorkflowRunPayloadBase -from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse -from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.common.fields import SimpleResultResponse +from controllers.common.schema import ( + query_params_from_model, + query_params_from_request, + register_response_schema_models, + register_schema_models, +) from controllers.service_api import service_api_ns from controllers.service_api.app.error import ( CompletionRequestError, @@ -41,14 +46,13 @@ from extensions.ext_redis import redis_client from fields.base import ResponseModel from fields.end_user_fields import SimpleEndUser -from fields.member_fields import SimpleAccount +from fields.member_fields import SimpleAccountResponse from graphon.enums import WorkflowExecutionStatus from graphon.graph_engine.manager import GraphEngineManager from graphon.model_runtime.errors.invoke import InvokeError from libs import helper -from libs.helper import to_timestamp +from libs.helper import dump_response, to_timestamp from models.model import App, AppMode, EndUser -from models.workflow import WorkflowRun from repositories.factory import DifyAPIRepositoryFactory from services.app_generate_service import AppGenerateService from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError @@ -97,36 +101,19 @@ class WorkflowLogQuery(BaseModel): register_schema_models(service_api_ns, WorkflowRunPayload, WorkflowLogQuery) -register_response_schema_models(service_api_ns, GeneratedAppResponse, SimpleResultResponse) +register_response_schema_models(service_api_ns, SimpleResultResponse) def _enum_value(value): return getattr(value, "value", value) -class WorkflowRunStatusField(fields.Raw): - @override - def output(self, key, obj: WorkflowRun, **kwargs): - return _enum_value(obj.status) - - -class WorkflowRunOutputsField(fields.Raw): - @override - def output(self, key, obj: WorkflowRun, **kwargs): - status = _enum_value(obj.status) - if status == WorkflowExecutionStatus.PAUSED.value: - return {} - - outputs = obj.outputs_dict - return outputs or {} - - class WorkflowRunResponse(ResponseModel): id: str workflow_id: str status: str inputs: dict | list | str | int | float | bool | None = Field(default=None) - outputs: dict = Field(default_factory=dict) + outputs: dict = Field(default_factory=dict, validation_alias="outputs_dict") error: str | None = None total_steps: int | None = None total_tokens: int | None = None @@ -134,11 +121,33 @@ class WorkflowRunResponse(ResponseModel): finished_at: int | None = None elapsed_time: float | int | None = None + @field_validator("status", mode="before") + @classmethod + def _normalize_enum(cls, value): + return _enum_value(value) + + @field_validator("outputs", mode="before") + @classmethod + def _normalize_outputs(cls, value): + if value is None: + return {} + if isinstance(value, dict): + return value + if isinstance(value, Mapping): + return dict(value) + return {} + @field_validator("created_at", "finished_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) + @model_validator(mode="after") + def _clear_paused_outputs(self): + if self.status == WorkflowExecutionStatus.PAUSED.value: + self.outputs = {} + return self + class WorkflowRunForLogResponse(ResponseModel): id: str @@ -170,7 +179,7 @@ class WorkflowAppLogPartialResponse(ResponseModel): details: dict | list | str | int | float | bool | None = Field(default=None) created_from: str | None = None created_by_role: str | None = None - created_by_account: SimpleAccount | None = None + created_by_account: SimpleAccountResponse | None = None created_by_end_user: SimpleEndUser | None = None created_at: int | None = None @@ -202,39 +211,6 @@ class WorkflowAppLogPaginationResponse(ResponseModel): ) -def _serialize_workflow_run(workflow_run: WorkflowRun) -> dict: - status = _enum_value(workflow_run.status) - raw_outputs = workflow_run.outputs_dict - match raw_outputs: - case _ if status == WorkflowExecutionStatus.PAUSED.value or raw_outputs is None: - outputs: dict = {} - case dict(): - outputs = raw_outputs - case _ if isinstance(raw_outputs, Mapping): - outputs = dict(raw_outputs) - case _: - outputs = {} - return WorkflowRunResponse.model_validate( - { - "id": workflow_run.id, - "workflow_id": workflow_run.workflow_id, - "status": status, - "inputs": workflow_run.inputs, - "outputs": outputs, - "error": workflow_run.error, - "total_steps": workflow_run.total_steps, - "total_tokens": workflow_run.total_tokens, - "created_at": workflow_run.created_at, - "finished_at": workflow_run.finished_at, - "elapsed_time": workflow_run.elapsed_time, - } - ).model_dump(mode="json") - - -def _serialize_workflow_log_pagination(pagination) -> dict: - return WorkflowAppLogPaginationResponse.model_validate(pagination, from_attributes=True).model_dump(mode="json") - - @service_api_ns.route("/workflows/run/") class WorkflowRunDetailApi(Resource): @service_api_ns.doc( @@ -287,7 +263,7 @@ def get(self, app_model: App, workflow_run_id: str): ) if not workflow_run: raise NotFound("Workflow run not found.") - return _serialize_workflow_run(workflow_run) + return dump_response(WorkflowRunResponse, workflow_run) @service_api_ns.route("/workflows/run") @@ -338,7 +314,6 @@ class WorkflowRunApi(Resource): @service_api_ns.response( 200, "Workflow executed successfully", - service_api_ns.models[GeneratedAppResponse.__name__], ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) def post(self, app_model: App, end_user: EndUser): @@ -366,6 +341,7 @@ def post(self, app_model: App, end_user: EndUser): app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=streaming ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) @@ -445,7 +421,6 @@ class WorkflowRunByIdApi(Resource): @service_api_ns.response( 200, "Workflow executed successfully", - service_api_ns.models[GeneratedAppResponse.__name__], ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) def post(self, app_model: App, end_user: EndUser, workflow_id: str): @@ -476,6 +451,7 @@ def post(self, app_model: App, end_user: EndUser, workflow_id: str): app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=streaming ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except WorkflowNotFoundError as ex: raise NotFound(str(ex)) @@ -541,7 +517,7 @@ def post(self, app_model: App, end_user: EndUser, task_id: str): # New graph engine command channel mechanism GraphEngineManager(redis_client).send_stop_command(task_id) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump() @service_api_ns.route("/workflows/logs") @@ -574,7 +550,7 @@ def get(self, app_model: App): Returns paginated workflow execution logs with filtering options. """ - args = WorkflowLogQuery.model_validate(request.args.to_dict()) + args = query_params_from_request(WorkflowLogQuery) status = WorkflowExecutionStatus(args.status) if args.status else None created_at_before = isoparse(args.created_at__before) if args.created_at__before else None @@ -596,4 +572,4 @@ def get(self, app_model: App): created_by_account=args.created_by_account, ) - return _serialize_workflow_log_pagination(workflow_app_log_pagination) + return dump_response(WorkflowAppLogPaginationResponse, workflow_app_log_pagination) diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index 292c39f69bc20c..c12d02ae826762 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -837,7 +837,7 @@ def patch(self, tenant_id, dataset_id: UUID, action: Literal["enable", "disable" except ValueError as e: raise InvalidActionError(str(e)) - return dump_response(SimpleResultResponse, {"result": "success"}), 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @service_api_ns.route("/datasets/tags") @@ -903,11 +903,8 @@ def post(self, _): payload = TagCreatePayload.model_validate(service_api_ns.payload or {}) tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=TagType.KNOWLEDGE), db.session) - response = dump_response( - KnowledgeTagResponse, - {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}, - ) - return response, 200 + response = KnowledgeTagResponse(id=tag.id, name=tag.name, type=tag.type, binding_count="0") + return response.model_dump(mode="json"), 200 @service_api_ns.doc( summary="Update Knowledge Tag", @@ -943,11 +940,8 @@ def patch(self, _): binding_count = TagService.get_tag_binding_count(tag_id, db.session) - response = dump_response( - KnowledgeTagResponse, - {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count}, - ) - return response, 200 + response = KnowledgeTagResponse(id=tag.id, name=tag.name, type=tag.type, binding_count=str(binding_count)) + return response.model_dump(mode="json"), 200 @service_api_ns.doc( summary="Delete Knowledge Tag", @@ -1078,5 +1072,8 @@ def get(self, _, *args, **kwargs): tags = TagService.get_tags_by_target_id( "knowledge", current_user.current_tenant_id, str(dataset_id), db.session ) - tags_list = [{"id": tag.id, "name": tag.name} for tag in tags] - return dump_response(DatasetBoundTagListResponse, {"data": tags_list, "total": len(tags)}), 200 + response = DatasetBoundTagListResponse( + data=[DatasetBoundTagResponse(id=tag.id, name=tag.name) for tag in tags], + total=len(tags), + ) + return response.model_dump(mode="json"), 200 diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index 9bae862814a498..08bc69bede4c3d 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -6,14 +6,22 @@ """ import json -from collections.abc import Mapping from contextlib import ExitStack from copy import deepcopy from typing import Annotated, Any, Literal, Self, override from uuid import UUID from flask import request, send_file -from pydantic import BaseModel, Field, GetJsonSchemaHandler, WithJsonSchema, field_validator, model_validator +from pydantic import ( + BaseModel, + Field, + GetJsonSchemaHandler, + ValidationError, + WithJsonSchema, + field_validator, + model_validator, +) +from pydantic.json_schema import SkipJsonSchema from sqlalchemy import desc, func, select from werkzeug.exceptions import Forbidden, NotFound @@ -26,9 +34,10 @@ TooManyFilesError, UnsupportedFileTypeError, ) -from controllers.common.fields import BinaryFileResponse, UrlResponse +from controllers.common.fields import UrlResponse from controllers.common.schema import ( query_params_from_model, + query_params_from_request, register_enum_models, register_response_schema_models, register_schema_models, @@ -56,6 +65,7 @@ DocumentMetadataResponse, DocumentResponse, DocumentStatusListResponse, + normalize_enum, ) from libs.helper import dump_response from libs.login import current_user @@ -280,38 +290,44 @@ class DocumentAndBatchResponse(ResponseModel): batch: str +# Use SkipJsonSchema to support 3 metadata modes class DocumentDetailResponse(ResponseModel): id: str - position: int | None = None - data_source_type: str | None = None - data_source_info: dict[str, Any] | None = Field(default=None) + position: int | SkipJsonSchema[None] = None + data_source_type: str | SkipJsonSchema[None] = None + data_source_info: dict[str, Any] | SkipJsonSchema[None] = None dataset_process_rule_id: str | None = None - dataset_process_rule: dict[str, Any] | None = Field(default=None) - document_process_rule: dict[str, Any] | None = Field(default=None) - name: str | None = None - created_from: str | None = None - created_by: str | None = None - created_at: int | None = None + dataset_process_rule: dict[str, Any] | SkipJsonSchema[None] = None + document_process_rule: dict[str, Any] | SkipJsonSchema[None] = None + name: str | SkipJsonSchema[None] = None + created_from: str | SkipJsonSchema[None] = None + created_by: str | SkipJsonSchema[None] = None + created_at: int | SkipJsonSchema[None] = None tokens: int | None = None - indexing_status: str | None = None + indexing_status: str | SkipJsonSchema[None] = None completed_at: int | None = None updated_at: int | None = None indexing_latency: float | None = None error: str | None = None - enabled: bool | None = None + enabled: bool | SkipJsonSchema[None] = None disabled_at: int | None = None disabled_by: str | None = None - archived: bool | None = None + archived: bool | SkipJsonSchema[None] = None doc_type: str | None = None - doc_metadata: list[DocumentMetadataResponse] | None = None - segment_count: int | None = None - average_segment_length: float | None = None - hit_count: int | None = None + doc_metadata: list[DocumentMetadataResponse] | dict[str, Any] | None = None + segment_count: int | SkipJsonSchema[None] = None + average_segment_length: int | float | SkipJsonSchema[None] = None + hit_count: int | SkipJsonSchema[None] = None display_status: str | None = None - doc_form: str | None = None + doc_form: str | SkipJsonSchema[None] = None doc_language: str | None = None summary_index_status: str | None = None - need_summary: bool | None = None + need_summary: bool | SkipJsonSchema[None] = None + + @field_validator("data_source_type", "indexing_status", "display_status", "doc_form", mode="before") + @classmethod + def _normalize_enum_fields(cls, value: Any) -> Any: + return normalize_enum(value) register_enum_models(service_api_ns, RetrievalMethod) @@ -331,7 +347,6 @@ class DocumentDetailResponse(ResponseModel): ) register_response_schema_models( service_api_ns, - BinaryFileResponse, UrlResponse, DocumentResponse, DocumentAndBatchResponse, @@ -341,13 +356,13 @@ class DocumentDetailResponse(ResponseModel): ) -def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Mapping[str, object], int]: +def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Document, str]: """Create a document from text for both canonical and legacy routes.""" payload = DocumentTextCreatePayload.model_validate(service_api_ns.payload or {}) args = payload.model_dump(exclude_none=True) dataset_id_str = str(dataset_id) - tenant_id_str = str(tenant_id) + tenant_id_str = tenant_id dataset = db.session.scalar( select(Dataset).where(Dataset.tenant_id == tenant_id_str, Dataset.id == dataset_id_str).limit(1) ) @@ -405,10 +420,10 @@ def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Mapping[ raise ProviderNotInitializeError(ex.description) document = documents[0] - return dump_response(DocumentAndBatchResponse, {"document": document, "batch": batch}), 200 + return document, batch -def _update_document_by_text(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Mapping[str, object], int]: +def _update_document_by_text(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Document, str]: """Update a document from text for both canonical and legacy routes.""" payload = DocumentTextUpdate.model_validate(service_api_ns.payload or {}) dataset = db.session.scalar( @@ -464,7 +479,7 @@ def _update_document_by_text(tenant_id: str, dataset_id: UUID, document_id: UUID raise ProviderNotInitializeError(ex.description) document = documents[0] - return dump_response(DocumentAndBatchResponse, {"document": document, "batch": batch}), 200 + return document, batch @service_api_ns.route("/datasets//document/create-by-text") @@ -508,7 +523,8 @@ class DocumentAddByTextApi(DatasetApiResource): @cloud_edition_billing_rate_limit_check("knowledge", "dataset") def post(self, tenant_id: str, dataset_id: UUID): """Create document by text.""" - return _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id) + document, batch = _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id) + return dump_response(DocumentAndBatchResponse, {"document": document, "batch": batch}), 200 @service_api_ns.route("/datasets//document/create_by_text") @@ -540,7 +556,8 @@ class DeprecatedDocumentAddByTextApi(DatasetApiResource): @cloud_edition_billing_rate_limit_check("knowledge", "dataset") def post(self, tenant_id: str, dataset_id: UUID): """Create document by text through the deprecated underscore alias.""" - return _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id) + document, batch = _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id) + return dump_response(DocumentAndBatchResponse, {"document": document, "batch": batch}), 200 @service_api_ns.route("/datasets//documents//update-by-text") @@ -584,7 +601,8 @@ class DocumentUpdateByTextApi(DatasetApiResource): @cloud_edition_billing_rate_limit_check("knowledge", "dataset") def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID): """Update document by text.""" - return _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id) + document, batch = _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id) + return dump_response(DocumentAndBatchResponse, {"document": document, "batch": batch}), 200 @service_api_ns.route("/datasets//documents//update_by_text") @@ -615,7 +633,8 @@ class DeprecatedDocumentUpdateByTextApi(DatasetApiResource): @cloud_edition_billing_rate_limit_check("knowledge", "dataset") def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID): """Update document by text through the deprecated underscore alias.""" - return _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id) + document, batch = _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id) + return dump_response(DocumentAndBatchResponse, {"document": document, "batch": batch}), 200 @service_api_ns.route( @@ -763,10 +782,10 @@ def post(self, tenant_id, dataset_id: UUID): return dump_response(DocumentAndBatchResponse, {"document": document, "batch": batch}), 200 -def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Mapping[str, object], int]: +def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Document, str]: """Update a document from an uploaded file for canonical and deprecated routes.""" dataset_id_str = str(dataset_id) - tenant_id_str = str(tenant_id) + tenant_id_str = tenant_id dataset = db.session.scalar( select(Dataset).where(Dataset.tenant_id == tenant_id_str, Dataset.id == dataset_id_str).limit(1) ) @@ -836,7 +855,7 @@ def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) document = documents[0] - return dump_response(DocumentAndBatchResponse, {"document": document, "batch": document.batch}), 200 + return document, document.batch @service_api_ns.route( @@ -890,7 +909,8 @@ class DeprecatedDocumentUpdateByFileApi(DatasetApiResource): @cloud_edition_billing_rate_limit_check("knowledge", "dataset") def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID): """Update document by file through the deprecated file-update aliases.""" - return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id) + document, batch = _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id) + return dump_response(DocumentAndBatchResponse, {"document": document, "batch": batch}), 200 @service_api_ns.route("/datasets//documents") @@ -923,7 +943,7 @@ class DocumentListApi(DatasetApiResource): def get(self, tenant_id, dataset_id: UUID): dataset_id_str = str(dataset_id) tenant_id = str(tenant_id) - query_params = DocumentListQuery.model_validate(request.args.to_dict()) + query_params = query_params_from_request(DocumentListQuery) dataset = db.session.scalar( select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id_str).limit(1) ) @@ -1014,6 +1034,7 @@ def post(self, tenant_id, dataset_id: UUID): ) cleanup = stack.pop_all() response.call_on_close(cleanup.close) + # response-contract:ignore binary send_file response return response @@ -1142,7 +1163,7 @@ def get(self, tenant_id, dataset_id: UUID, document_id: UUID): if document.tenant_id != str(tenant_id): raise Forbidden("No permission.") - return {"url": DocumentService.get_document_download_url(document)} + return UrlResponse(url=DocumentService.get_document_download_url(document)).model_dump(mode="json") @service_api_ns.route("/datasets//documents/") @@ -1169,8 +1190,13 @@ class DocumentApi(DatasetApiResource): ) @service_api_ns.doc("get_document") @service_api_ns.doc(description="Get a specific document by ID") - @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."}) - @service_api_ns.doc(params=query_params_from_model(DocumentGetQuery)) + @service_api_ns.doc( + params={ + "dataset_id": "Knowledge base ID.", + "document_id": "Document ID.", + **query_params_from_model(DocumentGetQuery), + } + ) @service_api_ns.doc( responses={ 200: "Document retrieved successfully", @@ -1198,9 +1224,14 @@ def get(self, tenant_id, dataset_id: UUID, document_id: UUID): if document.tenant_id != str(tenant_id): raise Forbidden("No permission.") - metadata = request.args.get("metadata", "all") - if metadata not in self.METADATA_CHOICES: - raise InvalidMetadataError(f"Invalid metadata value: {metadata}") + try: + query_params = query_params_from_request(DocumentGetQuery) + except ValidationError as exc: + metadata = request.args.get("metadata", "all") + raise InvalidMetadataError(f"Invalid metadata value: {metadata}") from exc + metadata = query_params.metadata + response_include: set[str] | None = None + response_exclude: set[str] | None = None # Calculate summary_index_status if needed summary_index_status = None @@ -1213,8 +1244,10 @@ def get(self, tenant_id, dataset_id: UUID, document_id: UUID): ) if metadata == "only": + response_include = {"id", "doc_type", "doc_metadata"} response = {"id": document.id, "doc_type": document.doc_type, "doc_metadata": document.doc_metadata_details} elif metadata == "without": + response_exclude = {"doc_type", "doc_metadata"} dataset_process_rules = DatasetService.get_process_rules(dataset_id_str) document_process_rules = document.dataset_process_rule.to_dict() if document.dataset_process_rule else {} data_source_info = document.data_source_detail_dict @@ -1287,8 +1320,33 @@ def get(self, tenant_id, dataset_id: UUID, document_id: UUID): "need_summary": document.need_summary if document.need_summary is not None else False, } - return response + return DocumentDetailResponse.model_validate(response).model_dump( + mode="json", + include=response_include, + exclude=response_exclude, + ) + @service_api_ns.doc( + summary="Update Document by File", + description=( + "Update an existing document by uploading a new file. Re-triggers indexing — use the returned " + "`batch` ID with [Get Document Indexing Status](/api-reference/documents/" + "get-document-indexing-status) to track progress." + ), + tags=["Documents"], + responses={ + 200: "Document updated successfully.", + 400: ( + "- `too_many_files` : Only one file is allowed.\n" + "- `filename_not_exists_error` : The specified filename does not exist.\n" + "- `provider_not_initialize` : No valid model provider credentials found. Please go to " + "Settings -> Model Provider to complete your provider credentials.\n" + "- `invalid_param` : Knowledge base does not exist, external datasets not supported, " + "file too large, unsupported file type, or invalid doc_form (must be `text_model`, " + "`hierarchical_model`, or `qa_model`)." + ), + }, + ) @service_api_ns.doc("update_document_by_file") @service_api_ns.doc(description="Update an existing document by uploading a file") @service_api_ns.doc(consumes=["multipart/form-data"], params=DOCUMENT_UPDATE_BY_FILE_PARAMS) @@ -1306,7 +1364,8 @@ def get(self, tenant_id, dataset_id: UUID, document_id: UUID): @cloud_edition_billing_rate_limit_check("knowledge", "dataset") def patch(self, tenant_id: str, dataset_id: UUID, document_id: UUID): """Update document by file on the canonical document resource.""" - return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id) + document, batch = _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id) + return dump_response(DocumentAndBatchResponse, {"document": document, "batch": batch}), 200 @service_api_ns.doc( summary="Delete Document", diff --git a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py index a6a61262cdc89f..0b28f4d10c8299 100644 --- a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py @@ -1,34 +1,30 @@ from collections.abc import Generator +from datetime import datetime from typing import Any from uuid import UUID from flask import request -from pydantic import BaseModel, Field, RootModel +from pydantic import BaseModel, Field, RootModel, field_validator from sqlalchemy import select from werkzeug.exceptions import Forbidden, NotFound import services from controllers.common.errors import FilenameNotExistsError, NoFileUploadedError, TooManyFilesError -from controllers.common.fields import GeneratedAppResponse from controllers.common.schema import ( query_params_from_model, + query_params_from_request, register_response_schema_models, register_schema_model, - register_schema_models, ) from controllers.service_api import service_api_ns from controllers.service_api.dataset.error import PipelineRunError -from controllers.service_api.dataset.rag_pipeline.serializers import serialize_upload_file -from controllers.service_api.schema import ( - event_stream_response, - json_or_event_stream_response, - multipart_file_params, -) +from controllers.service_api.schema import event_stream_response, json_or_event_stream_response, multipart_file_params from controllers.service_api.wraps import DatasetApiResource from core.app.apps.pipeline.pipeline_generator import PipelineGenerator from core.app.entities.app_invoke_entities import InvokeFrom from fields.base import ResponseModel from libs import helper +from libs.helper import dump_response from libs.login import current_user from models import Account from models.dataset import Dataset, Pipeline @@ -82,7 +78,7 @@ class DatasourcePluginResponse(ResponseModel): datasource_type: str | None = None title: str | None = None user_input_variables: list[dict[str, Any]] = Field(default_factory=list) - credentials: list[DatasourceCredentialInfoResponse] + credentials: list[DatasourceCredentialInfoResponse] = Field(default_factory=list) class DatasourcePluginListResponse(RootModel[list[DatasourcePluginResponse]]): @@ -98,14 +94,22 @@ class PipelineUploadFileResponse(ResponseModel): created_by: str created_at: str | None = None + @field_validator("created_at", mode="before") + @classmethod + def _normalize_created_at(cls, value: datetime | str | None) -> str | None: + if isinstance(value, datetime): + return value.isoformat() + return value + register_schema_model(service_api_ns, DatasourceNodeRunPayload) +register_schema_model(service_api_ns, DatasourcePluginsQuery) register_schema_model(service_api_ns, PipelineRunApiEntity) -register_schema_models(service_api_ns, DatasourcePluginsQuery) register_response_schema_models( service_api_ns, + DatasourceCredentialInfoResponse, + DatasourcePluginResponse, DatasourcePluginListResponse, - GeneratedAppResponse, PipelineUploadFileResponse, ) @@ -117,8 +121,8 @@ class DatasourcePluginsApi(DatasetApiResource): @service_api_ns.doc( summary="List Datasource Plugins", description=( - "List the datasource nodes configured in the knowledge pipeline. Each node includes the " - "plugin it uses plus the metadata needed to run it." + "List the datasource nodes configured in the knowledge pipeline. Each node includes the plugin it uses " + "plus the metadata needed to run it." ), tags=["Knowledge Pipeline"], responses={ @@ -150,14 +154,13 @@ def get(self, tenant_id: str, dataset_id: UUID): if not dataset: raise NotFound("Dataset not found.") - # Get query parameter to determine published or draft - is_published: bool = request.args.get("is_published", default=True, type=bool) + query = query_params_from_request(DatasourcePluginsQuery) rag_pipeline_service: RagPipelineService = RagPipelineService() datasource_plugins: list[dict[Any, Any]] = rag_pipeline_service.get_datasource_plugins( - tenant_id=tenant_id, dataset_id=dataset_id_str, is_published=is_published + tenant_id=tenant_id, dataset_id=dataset_id_str, is_published=query.is_published ) - return datasource_plugins, 200 + return dump_response(DatasourcePluginListResponse, datasource_plugins), 200 @service_api_ns.route("/datasets//pipeline/datasource/nodes//run") @@ -167,8 +170,8 @@ class DatasourceNodeRunApi(DatasetApiResource): @service_api_ns.doc( summary="Run Datasource Node", description=( - "Execute a single datasource node within the knowledge pipeline. Returns a streaming " - "response with the node execution results." + "Execute a single datasource node within the knowledge pipeline. Returns a streaming response with the " + "node execution results." ), tags=["Knowledge Pipeline"], responses={ @@ -187,11 +190,6 @@ class DatasourceNodeRunApi(DatasetApiResource): } ) @service_api_ns.expect(service_api_ns.models[DatasourceNodeRunPayload.__name__]) - @service_api_ns.response( - 200, - "Datasource node run successfully", - service_api_ns.models[GeneratedAppResponse.__name__], - ) def post(self, tenant_id: str, dataset_id: UUID, node_id: str): """Resource for getting datasource plugins.""" dataset_id_str = str(dataset_id) @@ -208,10 +206,11 @@ def post(self, tenant_id: str, dataset_id: UUID, node_id: str): datasource_node_run_api_entity = DatasourceNodeRunApiEntity.model_validate( { **payload.model_dump(exclude_none=True), - "pipeline_id": str(pipeline.id), + "pipeline_id": pipeline.id, "node_id": node_id, } ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response( PipelineGenerator.convert_to_event_stream( rag_pipeline_service.run_datasource_workflow_node( @@ -234,8 +233,8 @@ class PipelineRunApi(DatasetApiResource): @service_api_ns.doc( summary="Run Pipeline", description=( - "Execute the full knowledge pipeline for a knowledge base. Supports both streaming and " - "blocking response modes." + "Execute the full knowledge pipeline for a knowledge base. Supports both streaming and blocking response " + "modes." ), tags=["Knowledge Pipeline"], responses={ @@ -259,11 +258,6 @@ class PipelineRunApi(DatasetApiResource): } ) @service_api_ns.expect(service_api_ns.models[PipelineRunApiEntity.__name__]) - @service_api_ns.response( - 200, - "Pipeline run successfully", - service_api_ns.models[GeneratedAppResponse.__name__], - ) def post(self, tenant_id: str, dataset_id: UUID): """Resource for running a rag pipeline.""" dataset_id_str = str(dataset_id) @@ -289,6 +283,7 @@ def post(self, tenant_id: str, dataset_id: UUID): streaming=payload.response_mode == "streaming", ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except Exception as ex: raise PipelineRunError(description=str(ex)) @@ -364,4 +359,4 @@ def post(self, tenant_id: str): except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError() - return serialize_upload_file(upload_file), 201 + return dump_response(PipelineUploadFileResponse, upload_file), 201 diff --git a/api/controllers/service_api/dataset/rag_pipeline/serializers.py b/api/controllers/service_api/dataset/rag_pipeline/serializers.py deleted file mode 100644 index a5e8484037e8e7..00000000000000 --- a/api/controllers/service_api/dataset/rag_pipeline/serializers.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Serialization helpers for Service API knowledge pipeline endpoints. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, TypedDict - -if TYPE_CHECKING: - from models.model import UploadFile - - -class UploadFileDict(TypedDict): - id: str - name: str - size: int - extension: str - mime_type: str | None - created_by: str - created_at: str | None - - -def serialize_upload_file(upload_file: UploadFile) -> UploadFileDict: - return { - "id": upload_file.id, - "name": upload_file.name, - "size": upload_file.size, - "extension": upload_file.extension, - "mime_type": upload_file.mime_type, - "created_by": upload_file.created_by, - "created_at": upload_file.created_at.isoformat() if upload_file.created_at else None, - } diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index 801c1f5a629e56..3d47494edf7a4b 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -1,13 +1,11 @@ import logging from flask import request -from flask_restx import fields, marshal_with from pydantic import field_validator from werkzeug.exceptions import InternalServerError import services from controllers.common.controller_schemas import TextToAudioPayload as TextToAudioPayloadBase -from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse from controllers.web import web_ns from controllers.web.error import ( AppUnavailableError, @@ -23,8 +21,9 @@ from controllers.web.wraps import WebApiResource from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from extensions.ext_database import db +from fields.base import ResponseModel from graphon.model_runtime.errors.invoke import InvokeError -from libs.helper import uuid_value +from libs.helper import dump_response, uuid_value from models.model import App, EndUser from services.audio_service import AudioService from services.errors.audio import ( @@ -37,6 +36,10 @@ from ..common.schema import register_response_schema_models, register_schema_models +class AudioToTextResponse(ResponseModel): + text: str + + class TextToAudioPayload(TextToAudioPayloadBase): @field_validator("message_id") @classmethod @@ -47,18 +50,13 @@ def validate_message_id(cls, value: str | None) -> str | None: register_schema_models(web_ns, TextToAudioPayload) -register_response_schema_models(web_ns, AudioBinaryResponse, AudioTranscriptResponse) +register_response_schema_models(web_ns, AudioToTextResponse) logger = logging.getLogger(__name__) @web_ns.route("/audio-to-text") class AudioApi(WebApiResource): - audio_to_text_response_fields = { - "text": fields.String, - } - - @marshal_with(audio_to_text_response_fields) @web_ns.doc("Audio to Text") @web_ns.doc(description="Convert audio file to text using speech-to-text service.") @web_ns.doc( @@ -72,7 +70,7 @@ class AudioApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[AudioTranscriptResponse.__name__]) + @web_ns.response(200, "Success", web_ns.models[AudioToTextResponse.__name__]) def post(self, app_model: App, end_user: EndUser): """Convert audio to text""" file = request.files["file"] @@ -80,7 +78,7 @@ def post(self, app_model: App, end_user: EndUser): try: response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=end_user.external_user_id) - return response + return dump_response(AudioToTextResponse, response) except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") raise AppUnavailableError() @@ -121,7 +119,8 @@ class TextApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[AudioBinaryResponse.__name__]) + # response-contract:ignore provider audio bytes; TODO: model binary audio response if shape is standardized. + @web_ns.response(200, "Success") def post(self, app_model: App, end_user: EndUser): """Convert text to audio""" try: @@ -130,7 +129,7 @@ def post(self, app_model: App, end_user: EndUser): message_id = payload.message_id text = payload.text voice = payload.voice - response = AudioService.transcript_tts( + return AudioService.transcript_tts( app_model=app_model, session=db.session, text=text, @@ -138,8 +137,6 @@ def post(self, app_model: App, end_user: EndUser): end_user=end_user.external_user_id, message_id=message_id, ) - - return response except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") raise AppUnavailableError() diff --git a/api/controllers/web/completion.py b/api/controllers/web/completion.py index 7871b411c4bda3..34fd89dec53c5d 100644 --- a/api/controllers/web/completion.py +++ b/api/controllers/web/completion.py @@ -5,7 +5,7 @@ from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services -from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse +from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.web import web_ns from controllers.web.error import ( @@ -87,7 +87,7 @@ def validate_uuid(cls, value: str | None) -> str | None: register_schema_models(web_ns, CompletionMessagePayload, ChatMessagePayload) -register_response_schema_models(web_ns, GeneratedAppResponse, SimpleResultResponse) +register_response_schema_models(web_ns, SimpleResultResponse) # define completion api for user @@ -106,7 +106,7 @@ class CompletionApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) + @web_ns.response(200, "Success") def post(self, app_model: App, end_user: EndUser): if app_model.mode != AppMode.COMPLETION: raise NotCompletionAppError() @@ -122,6 +122,7 @@ def post(self, app_model: App, end_user: EndUser): app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.WEB_APP, streaming=streaming ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -172,7 +173,7 @@ def post(self, app_model: App, end_user: EndUser, task_id: str): app_mode=AppMode.value_of(app_model.mode), ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 @web_ns.route("/chat-messages") @@ -190,7 +191,7 @@ class ChatApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) + @web_ns.response(200, "Success") def post(self, app_model: App, end_user: EndUser): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}: @@ -213,6 +214,7 @@ def post(self, app_model: App, end_user: EndUser): app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.WEB_APP, streaming=streaming ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -266,4 +268,4 @@ def post(self, app_model: App, end_user: EndUser, task_id: str): app_mode=app_mode, ) - return {"result": "success"}, 200 + return SimpleResultResponse(result="success").model_dump(mode="json"), 200 diff --git a/api/controllers/web/files.py b/api/controllers/web/files.py index e08a3373643673..b87438f77b8dbe 100644 --- a/api/controllers/web/files.py +++ b/api/controllers/web/files.py @@ -13,6 +13,7 @@ from controllers.web.wraps import WebApiResource from extensions.ext_database import db from fields.file_fields import FileResponse +from libs.helper import dump_response from models.model import App, EndUser from services.file_service import FileService @@ -84,5 +85,4 @@ def post(self, app_model: App, end_user: EndUser): except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError() - response = FileResponse.model_validate(upload_file, from_attributes=True) - return response.model_dump(mode="json"), 201 + return dump_response(FileResponse, upload_file), 201 diff --git a/api/controllers/web/human_input_file_upload.py b/api/controllers/web/human_input_file_upload.py index cbb4a78529413b..5203951a9934de 100644 --- a/api/controllers/web/human_input_file_upload.py +++ b/api/controllers/web/human_input_file_upload.py @@ -29,6 +29,7 @@ from fields.file_fields import FileResponse, FileWithSignedUrl from graphon.file import helpers as file_helpers from libs.exception import BaseHTTPException +from libs.helper import dump_response from repositories.factory import DifyAPIRepositoryFactory from services.file_service import FileService from services.human_input_file_upload_service import ( @@ -141,8 +142,7 @@ def _upload_local_file(context): except services.errors.file.BlockedFileExtensionError as exc: raise BlockedFileExtensionError() from exc - response = FileResponse.model_validate(upload_file, from_attributes=True) - return upload_file.id, response + return upload_file.id, dump_response(FileResponse, upload_file) def _upload_remote_file(context, url: str): @@ -186,7 +186,7 @@ def _upload_remote_file(context, url: str): created_by=upload_file.created_by, created_at=int(upload_file.created_at.timestamp()), ) - return upload_file.id, response + return upload_file.id, response.model_dump(mode="json") @web_ns.route("/human-input-forms/files") @@ -209,4 +209,5 @@ def post(self): file_id, response = _upload_local_file(context=context) upload_service.record_upload_file(context=context, file_id=file_id) - return response.model_dump(mode="json"), 201 + # response-contract:ignore pre-dumped response. See above + return response, 201 diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py index 14b982dd23b584..ebf63db2458f6c 100644 --- a/api/controllers/web/human_input_form.py +++ b/api/controllers/web/human_input_form.py @@ -2,14 +2,12 @@ Web App Human Input Form APIs. """ -import json import logging from collections.abc import Sequence -from typing import Any, NotRequired, TypedDict +from typing import Self -from flask import Response, request +from flask import request from flask_restx import Resource -from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import select from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden @@ -20,35 +18,58 @@ from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.web import web_ns from controllers.web.error import WebFormRateLimitExceededError -from controllers.web.site import serialize_app_site_payload +from controllers.web.site import WebAppSiteResponse from extensions.ext_database import db -from graphon.nodes.human_input.entities import FormInputConfig -from libs.helper import RateLimiter, extract_remote_ip, to_timestamp +from fields.base import ResponseModel +from graphon.nodes.human_input.entities import FormInputConfig, UserActionConfig +from libs.helper import RateLimiter, dump_response, extract_remote_ip, to_timestamp from models.account import TenantStatus from models.model import App, Site from repositories.factory import DifyAPIRepositoryFactory +from services.feature_service import FeatureService from services.human_input_file_upload_service import HumanInputFileUploadService from services.human_input_service import Form, FormNotFoundError, HumanInputService logger = logging.getLogger(__name__) -class HumanInputUploadTokenResponse(BaseModel): +class HumanInputUploadTokenResponse(ResponseModel): upload_token: str expires_at: int -class HumanInputFormDefinitionResponse(BaseModel): - form_content: Any - inputs: Any +class HumanInputFormDefinitionResponse(ResponseModel): + form_content: str + inputs: list[FormInputConfig] resolved_default_values: dict[str, str] - user_actions: Any + user_actions: list[UserActionConfig] expiration_time: int - site: dict[str, Any] | None = Field(default=None) + site: WebAppSiteResponse | None = None + + @classmethod + def from_form( + cls, + form: Form, + *, + inputs: Sequence[FormInputConfig] = (), + site: WebAppSiteResponse | None = None, + ) -> Self: + definition_payload = form.get_definition().model_dump(mode="json") + expiration_time = to_timestamp(form.expiration_time) + if expiration_time is None: + raise ValueError("Human input form expiration_time is required") + return cls( + form_content=definition_payload["rendered_content"], + inputs=list(inputs), + resolved_default_values=stringify_form_default_values(definition_payload["default_values"]), + user_actions=definition_payload["user_actions"], + expiration_time=expiration_time, + site=site, + ) -class HumanInputFormSubmitResponse(BaseModel): - model_config = ConfigDict(extra="forbid") +class HumanInputFormSubmitResponse(ResponseModel): + pass register_schema_models(web_ns, HumanInputFormSubmitPayload) @@ -86,40 +107,26 @@ def _create_upload_service() -> HumanInputFileUploadService: ) -class FormDefinitionPayload(TypedDict): - form_content: Any - inputs: Any - resolved_default_values: dict[str, str] - user_actions: Any - expiration_time: int - site: NotRequired[dict] - - -def _jsonify_form_definition( - form: Form, - *, - inputs: Sequence[FormInputConfig] = (), - site_payload: dict | None = None, -) -> Response: - """Return the form payload (optionally with site) as a JSON response.""" - definition_payload = form.get_definition().model_dump(mode="json") - payload: FormDefinitionPayload = { - "form_content": definition_payload["rendered_content"], - "inputs": [i.model_dump(mode="json") for i in inputs], - "resolved_default_values": stringify_form_default_values(definition_payload["default_values"]), - "user_actions": definition_payload["user_actions"], - "expiration_time": to_timestamp(form.expiration_time), - } - if site_payload is not None: - payload["site"] = site_payload - return Response(json.dumps(payload, ensure_ascii=False), mimetype="application/json") - - @web_ns.route("/form/human_input//upload-token") class HumanInputFormUploadTokenApi(Resource): """API for issuing HITL upload tokens for active human input forms.""" - @web_ns.response(200, "Success", web_ns.models[HumanInputUploadTokenResponse.__name__]) + @web_ns.doc("create_human_input_form_upload_token") + @web_ns.doc(description="Issue an upload token for an active human input form") + @web_ns.doc(params={"form_token": "Human input form token"}) + @web_ns.doc( + responses={ + 200: "Upload token issued successfully", + 404: "Form not found", + 412: "Form already submitted or expired", + 429: "Too many requests", + } + ) + @web_ns.response( + 200, + "Upload token issued successfully", + web_ns.models[HumanInputUploadTokenResponse.__name__], + ) def post(self, form_token: str): """ Issue an upload token for a human input form. @@ -136,11 +143,9 @@ def post(self, form_token: str): except FormNotFoundError: raise NotFoundError("Form not found") - response = HumanInputUploadTokenResponse( - upload_token=token.upload_token, - expires_at=to_timestamp(token.expires_at), - ) - return response.model_dump(mode="json"), 200 + return HumanInputUploadTokenResponse( + upload_token=token.upload_token, expires_at=to_timestamp(token.expires_at) + ).model_dump(mode="json"), 200 @web_ns.route("/form/human_input/") @@ -150,7 +155,23 @@ class HumanInputFormApi(Resource): # NOTE(QuantumGhost): this endpoint is unauthenticated on purpose for now. # def get(self, _app_model: App, _end_user: EndUser, form_token: str): - @web_ns.response(200, "Success", web_ns.models[HumanInputFormDefinitionResponse.__name__]) + @web_ns.doc("get_human_input_form") + @web_ns.doc(description="Get a human input form definition by token") + @web_ns.doc(params={"form_token": "Human input form token"}) + @web_ns.doc( + responses={ + 200: "Form retrieved successfully", + 403: "Forbidden", + 404: "Form not found", + 412: "Form already submitted or expired", + 429: "Too many requests", + } + ) + @web_ns.response( + 200, + "Form retrieved successfully", + web_ns.models[HumanInputFormDefinitionResponse.__name__], + ) def get(self, form_token: str): """ Get human input form definition by token. @@ -172,17 +193,47 @@ def get(self, form_token: str): service.ensure_form_active(form) app_model, site = _get_app_site_from_form(form) + tenant = app_model.tenant + if tenant is None: + raise Forbidden() inputs = service.resolve_form_inputs(form) - return _jsonify_form_definition( - form, - inputs=inputs, - site_payload=serialize_app_site_payload(app_model, site, None), + return dump_response( + HumanInputFormDefinitionResponse, + HumanInputFormDefinitionResponse.from_form( + form, + inputs=inputs, + site=WebAppSiteResponse.from_app_site( + tenant=tenant, + app_model=app_model, + site=site, + end_user_id=None, + can_replace_logo=FeatureService.get_features( + app_model.tenant_id, exclude_vector_space=True + ).can_replace_logo, + ), + ), ) # def post(self, _app_model: App, _end_user: EndUser, form_token: str): - @web_ns.response(200, "Success", web_ns.models[HumanInputFormSubmitResponse.__name__]) @web_ns.expect(web_ns.models[HumanInputFormSubmitPayload.__name__]) + @web_ns.doc("submit_human_input_form") + @web_ns.doc(description="Submit a human input form by token") + @web_ns.doc(params={"form_token": "Human input form token"}) + @web_ns.doc( + responses={ + 200: "Form submitted successfully", + 400: "Bad request - invalid submission data", + 404: "Form not found", + 412: "Form already submitted or expired", + 429: "Too many requests", + } + ) + @web_ns.response( + 200, + "Form submitted successfully", + web_ns.models[HumanInputFormSubmitResponse.__name__], + ) def post(self, form_token: str): """ Submit human input form by token. @@ -225,7 +276,7 @@ def post(self, form_token: str): except FormNotFoundError: raise NotFoundError("Form not found") - return {}, 200 + return HumanInputFormSubmitResponse().model_dump(mode="json"), 200 def _get_app_site_from_form(form: Form) -> tuple[App, Site]: @@ -238,7 +289,7 @@ def _get_app_site_from_form(form: Form) -> tuple[App, Site]: if site is None: raise Forbidden() - if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE: + if app_model.tenant is None or app_model.tenant.status == TenantStatus.ARCHIVE: raise Forbidden() return app_model, site diff --git a/api/controllers/web/login.py b/api/controllers/web/login.py index 2d8c38f5507854..011bb43b8803ff 100644 --- a/api/controllers/web/login.py +++ b/api/controllers/web/login.py @@ -9,6 +9,7 @@ import services from configs import dify_config from controllers.common.fields import ( + AccessTokenData, AccessTokenResultResponse, LoginStatusResponse, SimpleResultDataResponse, @@ -115,9 +116,10 @@ def post(self): raise AuthenticationFailedError() token = WebAppAuthService.login(account=account) - response = make_response({"result": "success", "data": {"access_token": token}}) # set_access_token_to_cookie(request, response, token, samesite="None", httponly=False) - return response + return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump( + mode="json" + ) # this api helps frontend to check whether user is authenticated @@ -136,14 +138,12 @@ class LoginStatusApi(Resource): ) @web_ns.response(200, "Login status", web_ns.models[LoginStatusResponse.__name__]) def get(self): - app_code = request.args.get("app_code") - user_id = request.args.get("user_id") + query = LoginStatusQuery.model_validate(request.args.to_dict(flat=True)) + app_code = query.app_code + user_id = query.user_id token = extract_webapp_access_token(request) if not app_code: - return { - "logged_in": bool(token), - "app_logged_in": False, - } + return LoginStatusResponse(logged_in=bool(token), app_logged_in=False).model_dump(mode="json") app_id = AppService.get_app_id_by_code(app_code) is_public = not dify_config.ENTERPRISE_ENABLED or not WebAppAuthService.is_app_require_permission_check( app_id=app_id @@ -165,10 +165,7 @@ def get(self): except Exception: app_logged_in = False - return { - "logged_in": user_logged_in, - "app_logged_in": app_logged_in, - } + return LoginStatusResponse(logged_in=user_logged_in, app_logged_in=app_logged_in).model_dump(mode="json") @web_ns.route("/logout") @@ -183,7 +180,8 @@ class LogoutApi(Resource): ) @web_ns.response(200, "Logout successful", web_ns.models[SimpleResultResponse.__name__]) def post(self): - response = make_response({"result": "success"}) + # response-contract:ignore hand-crafted response + response = make_response(SimpleResultResponse(result="success").model_dump(mode="json")) # enterprise SSO sets same site to None in https deployment # so we need to logout by calling api clear_webapp_access_token_from_cookie(response, samesite="None") @@ -216,9 +214,8 @@ def post(self): account = WebAppAuthService.get_user_through_email(payload.email) if account is None: raise AuthenticationFailedError() - else: - token = WebAppAuthService.send_email_code_login_email(account=account, language=language) - return {"result": "success", "data": token} + token = WebAppAuthService.send_email_code_login_email(account=account, language=language) + return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json") @web_ns.route("/email-code-login/validity") @@ -277,9 +274,10 @@ def post(self): token = WebAppAuthService.login(account=account) AccountService.reset_login_error_rate_limit(user_email) - response = make_response({"result": "success", "data": {"access_token": token}}) # set_access_token_to_cookie(request, response, token, samesite="None", httponly=False) - return response + return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump( + mode="json" + ) def _log_web_login_failure(*, email: str, reason: LoginFailureReason) -> None: diff --git a/api/controllers/web/message.py b/api/controllers/web/message.py index 65ef02471a9f14..0ecf313660e82b 100644 --- a/api/controllers/web/message.py +++ b/api/controllers/web/message.py @@ -7,7 +7,6 @@ from werkzeug.exceptions import InternalServerError, NotFound from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery -from controllers.common.fields import GeneratedAppResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.web import web_ns from controllers.web.error import ( @@ -51,7 +50,6 @@ class MessageMoreLikeThisQuery(BaseModel): register_schema_models(web_ns, MessageListQuery, MessageFeedbackPayload, MessageMoreLikeThisQuery) register_response_schema_models( web_ns, - GeneratedAppResponse, ResultResponse, SuggestedQuestionsResponse, WebMessageInfiniteScrollPagination, @@ -161,7 +159,7 @@ class MessageMoreLikeThisApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) + @web_ns.response(200, "Success") def get(self, app_model: App, end_user: EndUser, message_id: UUID): if app_model.mode != "completion": raise NotCompletionAppError() @@ -182,6 +180,7 @@ def get(self, app_model: App, end_user: EndUser, message_id: UUID): streaming=streaming, ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except MessageNotExistsError: raise NotFound("Message Not Exists.") diff --git a/api/controllers/web/passport.py b/api/controllers/web/passport.py index 99b757762804a1..c11ce8247314bb 100644 --- a/api/controllers/web/passport.py +++ b/api/controllers/web/passport.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime, timedelta from typing import Any -from flask import make_response, request +from flask import request from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy import func, select @@ -10,11 +10,12 @@ from configs import dify_config from constants import HEADER_NAME_APP_CODE -from controllers.common.fields import AccessTokenData from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.web import web_ns from controllers.web.error import WebAppAuthRequiredError from extensions.ext_database import db +from fields.base import ResponseModel +from libs.helper import dump_response from libs.passport import PassportService from libs.token import extract_webapp_access_token from models.enums import EndUserType @@ -28,7 +29,13 @@ class PassportQuery(BaseModel): register_schema_models(web_ns, PassportQuery) -register_response_schema_models(web_ns, AccessTokenData) + + +class PassportAccessTokenResponse(ResponseModel): + access_token: str + + +register_response_schema_models(web_ns, PassportAccessTokenResponse) @web_ns.route("/passport") @@ -45,7 +52,7 @@ class PassportResource(Resource): 404: "Application or user not found", } ) - @web_ns.response(200, "Passport retrieved successfully", web_ns.models[AccessTokenData.__name__]) + @web_ns.response(200, "Passport retrieved successfully", web_ns.models[PassportAccessTokenResponse.__name__]) def get(self): system_features = FeatureService.get_system_features() app_code = request.headers.get(HEADER_NAME_APP_CODE) @@ -59,8 +66,11 @@ def get(self): if app_auth_type != WebAppAuthType.PUBLIC: if not enterprise_user_decoded: raise WebAppAuthRequiredError() - return exchange_token_for_existing_web_user( - app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type + return dump_response( + PassportAccessTokenResponse, + exchange_token_for_existing_web_user( + app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type + ), ) # get site from db and check if it is normal @@ -110,12 +120,7 @@ def get(self): tk = PassportService().issue(payload) - response = make_response( - { - "access_token": tk, - } - ) - return response + return dump_response(PassportAccessTokenResponse, {"access_token": tk}) def decode_enterprise_webapp_user_id(jwt_token: str | None) -> dict[str, Any] | None: @@ -206,12 +211,7 @@ def exchange_token_for_existing_web_user( "exp": exp, } token: str = PassportService().issue(payload) - resp = make_response( - { - "access_token": token, - } - ) - return resp + return {"access_token": token} def _exchange_for_public_app_token(app_model, site, token_decoded): @@ -244,12 +244,7 @@ def _exchange_for_public_app_token(app_model, site, token_decoded): tk = PassportService().issue(payload) - resp = make_response( - { - "access_token": tk, - } - ) - return resp + return {"access_token": tk} def generate_session_id(): diff --git a/api/controllers/web/remote_files.py b/api/controllers/web/remote_files.py index 1772300b5cda24..b12661dc5cfbac 100644 --- a/api/controllers/web/remote_files.py +++ b/api/controllers/web/remote_files.py @@ -65,11 +65,10 @@ def get(self, app_model: App, end_user: EndUser, url: str): # failed back to get method resp = remote_fetcher.make_request("GET", decoded_url, timeout=3) resp.raise_for_status() - info = RemoteFileInfo( + return RemoteFileInfo( file_type=resp.headers.get("Content-Type", "application/octet-stream"), file_length=int(resp.headers.get("Content-Length", -1)), - ) - return info.model_dump(mode="json") + ).model_dump(mode="json") @web_ns.route("/remote-files/upload") @@ -141,7 +140,7 @@ def post(self, app_model: App, end_user: EndUser): except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError - payload1 = FileWithSignedUrl( + return FileWithSignedUrl( id=upload_file.id, name=upload_file.name, size=upload_file.size, @@ -150,5 +149,4 @@ def post(self, app_model: App, end_user: EndUser): mime_type=upload_file.mime_type, created_by=upload_file.created_by, created_at=int(upload_file.created_at.timestamp()), - ) - return payload1.model_dump(mode="json"), 201 + ).model_dump(mode="json"), 201 diff --git a/api/controllers/web/saved_message.py b/api/controllers/web/saved_message.py index 6e59a85e2b046b..256b5377d7f69c 100644 --- a/api/controllers/web/saved_message.py +++ b/api/controllers/web/saved_message.py @@ -49,9 +49,7 @@ def get(self, app_model: App, end_user: EndUser): adapter = TypeAdapter(SavedMessageItem) items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data] return SavedMessageInfiniteScrollPagination( - limit=pagination.limit, - has_more=pagination.has_more, - data=items, + limit=pagination.limit, has_more=pagination.has_more, data=items ).model_dump(mode="json") @web_ns.doc("Save Message") @@ -102,6 +100,7 @@ class SavedMessageApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(204, "Message removed successfully") def delete(self, app_model: App, end_user: EndUser, message_id: UUID): message_id_str = str(message_id) diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 5e0f8326517255..e70cc2c2a1db23 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -1,7 +1,6 @@ -from typing import Any, cast +from typing import Any, Self -from flask_restx import fields, marshal, marshal_with -from pydantic import Field +from pydantic import AliasChoices, Field, computed_field from sqlalchemy import select from werkzeug.exceptions import Forbidden @@ -11,30 +10,19 @@ from controllers.web.wraps import WebApiResource from extensions.ext_database import db from fields.base import ResponseModel -from libs.helper import AppIconUrlField -from models.account import TenantStatus +from libs.helper import build_icon_url +from models.account import Tenant, TenantStatus from models.model import App, EndUser, Site from services.feature_service import FeatureService -class AppSiteModelConfigResponse(ResponseModel): - opening_statement: str | None = None - suggested_questions: Any - suggested_questions_after_answer: Any - more_like_this: Any - model: Any - user_input_form: Any - pre_prompt: str | None = None - - -class AppSiteResponse(ResponseModel): - title: str | None = None +class WebSiteResponse(ResponseModel): + title: str chat_color_theme: str | None = None - chat_color_theme_inverted: bool | None = None + chat_color_theme_inverted: bool icon_type: str | None = None icon: str | None = None icon_background: str | None = None - icon_url: str | None = None description: str | None = None copyright: str | None = None privacy_policy: str | None = None @@ -44,64 +32,92 @@ class AppSiteResponse(ResponseModel): show_workflow_steps: bool | None = None use_icon_as_answer_icon: bool | None = None + @computed_field(return_type=str | None) # type: ignore[prop-decorator] + @property + def icon_url(self) -> str | None: + return build_icon_url(self.icon_type, self.icon) + -class AppSiteInfoResponse(ResponseModel): +class WebModelConfigResponse(ResponseModel): + opening_statement: str | None = None + suggested_questions: Any = Field( + default=None, + validation_alias=AliasChoices("suggested_questions_list", "suggested_questions"), + ) + suggested_questions_after_answer: Any = Field( + default=None, + validation_alias=AliasChoices("suggested_questions_after_answer_dict", "suggested_questions_after_answer"), + ) + more_like_this: Any = Field( + default=None, + validation_alias=AliasChoices("more_like_this_dict", "more_like_this"), + ) + model: Any = Field(default=None, validation_alias=AliasChoices("model_dict", "model")) + user_input_form: Any = Field( + default=None, + validation_alias=AliasChoices("user_input_form_list", "user_input_form"), + ) + pre_prompt: str | None = None + + +class WebAppCustomConfigResponse(ResponseModel): + remove_webapp_brand: bool + replace_webapp_logo: str | None = None + + +class WebAppSiteResponse(ResponseModel): app_id: str end_user_id: str | None = None enable_site: bool - site: AppSiteResponse - model_config_: AppSiteModelConfigResponse | None = Field(default=None, alias="model_config") - plan: str | None = None + site: WebSiteResponse + model_config_: WebModelConfigResponse | None = Field( + default=None, validation_alias="model_config", serialization_alias="model_config" + ) + plan: str can_replace_logo: bool - custom_config: dict[str, Any] | None = Field(default=None) + custom_config: WebAppCustomConfigResponse | None = None + + @classmethod + def from_app_site( + cls, + *, + tenant: Tenant, + app_model: App, + site: Site, + end_user_id: str | None, + can_replace_logo: bool, + ) -> Self: + custom_config = None + if can_replace_logo: + replace_webapp_logo = ( + f"{dify_config.FILES_URL}/files/workspaces/{tenant.id}/webapp-logo" + if tenant.custom_config_dict.get("replace_webapp_logo") + else None + ) + custom_config = WebAppCustomConfigResponse( + remove_webapp_brand=tenant.custom_config_dict.get("remove_webapp_brand", False), + replace_webapp_logo=replace_webapp_logo, + ) + return cls( + app_id=app_model.id, + end_user_id=end_user_id, + enable_site=app_model.enable_site, + site=WebSiteResponse.model_validate(site, from_attributes=True), + model_config_=None, + plan=tenant.plan, + can_replace_logo=can_replace_logo, + custom_config=custom_config, + ) -register_response_schema_models(web_ns, AppSiteInfoResponse) + +register_response_schema_models( + web_ns, WebSiteResponse, WebModelConfigResponse, WebAppCustomConfigResponse, WebAppSiteResponse +) @web_ns.route("/site") class AppSiteApi(WebApiResource): - """Resource for app sites.""" - - model_config_fields = { - "opening_statement": fields.String, - "suggested_questions": fields.Raw(attribute="suggested_questions_list"), - "suggested_questions_after_answer": fields.Raw(attribute="suggested_questions_after_answer_dict"), - "more_like_this": fields.Raw(attribute="more_like_this_dict"), - "model": fields.Raw(attribute="model_dict"), - "user_input_form": fields.Raw(attribute="user_input_form_list"), - "pre_prompt": fields.String, - } - - site_fields = { - "title": fields.String, - "chat_color_theme": fields.String, - "chat_color_theme_inverted": fields.Boolean, - "icon_type": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "icon_url": AppIconUrlField, - "description": fields.String, - "copyright": fields.String, - "privacy_policy": fields.String, - "custom_disclaimer": fields.String, - "default_language": fields.String, - "prompt_public": fields.Boolean, - "show_workflow_steps": fields.Boolean, - "use_icon_as_answer_icon": fields.Boolean, - } - - app_fields = { - "app_id": fields.String, - "end_user_id": fields.String, - "enable_site": fields.Boolean, - "site": fields.Nested(site_fields), - "model_config": fields.Nested(model_config_fields, allow_null=True), - "plan": fields.String, - "can_replace_logo": fields.Boolean, - "custom_config": fields.Raw(attribute="custom_config"), - } - @web_ns.doc("Get App Site Info") @web_ns.doc(description="Retrieve app site information and configuration.") @web_ns.doc( @@ -114,57 +130,25 @@ class AppSiteApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[AppSiteInfoResponse.__name__]) - @marshal_with(app_fields) + @web_ns.response(200, "Success", web_ns.models[WebAppSiteResponse.__name__]) def get(self, app_model: App, end_user: EndUser): """Retrieve app site info.""" # get site site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) - if not site: + if site is None: raise Forbidden() - if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE: + tenant = app_model.tenant + if tenant is None or tenant.status == TenantStatus.ARCHIVE: raise Forbidden() can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo - return AppSiteInfo(app_model.tenant, app_model, site, end_user.id, can_replace_logo) - - -class AppSiteInfo: - """Class to store site information.""" - - def __init__(self, tenant, app, site, end_user, can_replace_logo): - """Initialize AppSiteInfo instance.""" - self.app_id = app.id - self.end_user_id = end_user - self.enable_site = app.enable_site - self.site = site - self.model_config = None - self.plan = tenant.plan - self.can_replace_logo = can_replace_logo - - if can_replace_logo: - base_url = dify_config.FILES_URL - remove_webapp_brand = tenant.custom_config_dict.get("remove_webapp_brand", False) - replace_webapp_logo = ( - f"{base_url}/files/workspaces/{tenant.id}/webapp-logo" - if tenant.custom_config_dict.get("replace_webapp_logo") - else None - ) - self.custom_config = { - "remove_webapp_brand": remove_webapp_brand, - "replace_webapp_logo": replace_webapp_logo, - } - - -def serialize_site(site: Site) -> dict[str, Any]: - """Serialize Site model using the same schema as AppSiteApi.""" - return cast(dict[str, Any], marshal(site, AppSiteApi.site_fields)) - - -def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict[str, Any]: - can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo - app_site_info = AppSiteInfo(app_model.tenant, app_model, site, end_user_id, can_replace_logo) - return cast(dict[str, Any], marshal(app_site_info, AppSiteApi.app_fields)) + return WebAppSiteResponse.from_app_site( + tenant=tenant, + app_model=app_model, + site=site, + end_user_id=end_user.id, + can_replace_logo=can_replace_logo, + ).model_dump(mode="json") diff --git a/api/controllers/web/workflow.py b/api/controllers/web/workflow.py index 06d9c02fedcf22..b380eccfe5fb99 100644 --- a/api/controllers/web/workflow.py +++ b/api/controllers/web/workflow.py @@ -3,7 +3,7 @@ from werkzeug.exceptions import InternalServerError from controllers.common.controller_schemas import WorkflowRunPayload -from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse +from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.web import web_ns from controllers.web.error import ( @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) register_schema_models(web_ns, WorkflowRunPayload) -register_response_schema_models(web_ns, GeneratedAppResponse, SimpleResultResponse) +register_response_schema_models(web_ns, SimpleResultResponse) @web_ns.route("/workflows/run") @@ -51,7 +51,7 @@ class WorkflowRunApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) + @web_ns.response(200, "Workflow run stream started") def post(self, app_model: App, end_user: EndUser): """ Run workflow @@ -68,6 +68,7 @@ def post(self, app_model: App, end_user: EndUser): app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.WEB_APP, streaming=True ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) @@ -121,4 +122,4 @@ def post(self, app_model: App, end_user: EndUser, task_id: str): # New graph engine command channel mechanism GraphEngineManager(redis_client).send_stop_command(task_id) - return {"result": "success"} + return SimpleResultResponse(result="success").model_dump(mode="json") diff --git a/api/core/rag/entities/processing_entities.py b/api/core/rag/entities/processing_entities.py index 46360ec086ff50..0677e4014e8f0a 100644 --- a/api/core/rag/entities/processing_entities.py +++ b/api/core/rag/entities/processing_entities.py @@ -1,7 +1,7 @@ from enum import StrEnum from typing import Annotated, Literal -from pydantic import BaseModel, Field, WithJsonSchema +from pydantic import AliasChoices, BaseModel, Field, WithJsonSchema class ParentMode(StrEnum): @@ -26,7 +26,14 @@ class PreProcessingRule(BaseModel): class Segmentation(BaseModel): - separator: str = Field(default="\n", description="Custom separator for splitting text.") + # TODO: there are internally mismatched / inconsistent naming + # between `separator`` and `delimiter` across the codebase. + # Taking `separator` as the canonical. + separator: str = Field( + default="\n", + description="Custom separator for splitting text.", + validation_alias=AliasChoices("separator", "delimiter"), + ) max_tokens: int = Field(description="Maximum token count per chunk.") chunk_overlap: int = Field(default=0, description="Token overlap between chunks.") diff --git a/api/core/rag/extractor/extract_processor.py b/api/core/rag/extractor/extract_processor.py index 4d11ebe5005620..36d879427a5d77 100644 --- a/api/core/rag/extractor/extract_processor.py +++ b/api/core/rag/extractor/extract_processor.py @@ -1,7 +1,7 @@ import re import tempfile from pathlib import Path -from typing import Union +from typing import Literal, overload from urllib.parse import unquote from configs import dify_config @@ -40,10 +40,22 @@ class ExtractProcessor: + @overload + @classmethod + def load_from_upload_file( + cls, upload_file: UploadFile, return_text: Literal[True], is_automatic: bool = False + ) -> str: ... + + @overload + @classmethod + def load_from_upload_file( + cls, upload_file: UploadFile, return_text: Literal[False] = False, is_automatic: bool = False + ) -> list[Document]: ... + @classmethod def load_from_upload_file( cls, upload_file: UploadFile, return_text: bool = False, is_automatic: bool = False - ) -> Union[list[Document], str]: + ) -> list[Document] | str: extract_setting = ExtractSetting( datasource_type=DatasourceType.FILE, upload_file=upload_file, document_model="text_model" ) @@ -53,8 +65,16 @@ def load_from_upload_file( else: return cls.extract(extract_setting, is_automatic) + @overload + @classmethod + def load_from_url(cls, url: str, return_text: Literal[True]) -> str: ... + + @overload + @classmethod + def load_from_url(cls, url: str, return_text: Literal[False] = False) -> list[Document]: ... + @classmethod - def load_from_url(cls, url: str, return_text: bool = False) -> Union[list[Document], str]: + def load_from_url(cls, url: str, return_text: bool = False) -> list[Document] | str: response = remote_fetcher.make_request("GET", url, headers={"User-Agent": USER_AGENT}) with tempfile.TemporaryDirectory() as temp_dir: diff --git a/api/core/tools/entities/api_entities.py b/api/core/tools/entities/api_entities.py index 0217300055fb89..0bee91ffe14369 100644 --- a/api/core/tools/entities/api_entities.py +++ b/api/core/tools/entities/api_entities.py @@ -74,8 +74,6 @@ def to_dict(self): for parameter in tool.get("parameters"): if parameter.get("type") == ToolParameter.ToolParameterType.SYSTEM_FILES: parameter["type"] = "files" - if parameter.get("input_schema") is None: - parameter.pop("input_schema", None) # ------------- optional_fields = self.optional_field("server_url", self.server_url) match self.type: diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index 850571c3f199e4..cafc2f78538f4d 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -16,30 +16,16 @@ import contexts from configs import dify_config -from core.entities import PluginCredentialType -from core.helper.provider_cache import ToolProviderCredentialsCache -from core.plugin.impl.tool import PluginToolManager -from core.tools.__base.tool_provider import ToolProviderController -from core.tools.__base.tool_runtime import ToolRuntime -from core.tools.mcp_tool.provider import MCPToolProviderController -from core.tools.mcp_tool.tool import MCPTool -from core.tools.plugin_tool.provider import PluginToolProviderController -from core.tools.plugin_tool.tool import PluginTool -from core.tools.utils.uuid_utils import is_valid_uuid -from core.tools.workflow_as_tool.provider import WorkflowToolProviderController -from extensions.ext_database import db -from graphon.runtime import VariablePool -from models.provider_ids import ToolProviderID -from services.tools.mcp_tools_manage_service import MCPToolManageService - -if TYPE_CHECKING: - pass - from core.agent.entities import AgentToolEntity from core.app.entities.app_invoke_entities import InvokeFrom +from core.entities import PluginCredentialType from core.helper.module_import_helper import load_single_subclass_from_source from core.helper.position_helper import is_filtered +from core.helper.provider_cache import ToolProviderCredentialsCache +from core.plugin.impl.tool import PluginToolManager from core.tools.__base.tool import Tool +from core.tools.__base.tool_provider import ToolProviderController +from core.tools.__base.tool_runtime import ToolRuntime from core.tools.builtin_tool.provider import BuiltinToolProviderController from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort from core.tools.builtin_tool.tool import BuiltinTool @@ -56,12 +42,21 @@ emoji_icon_adapter, ) from core.tools.errors import ToolProviderNotFoundError +from core.tools.mcp_tool.provider import MCPToolProviderController +from core.tools.mcp_tool.tool import MCPTool +from core.tools.plugin_tool.provider import PluginToolProviderController +from core.tools.plugin_tool.tool import PluginTool from core.tools.tool_label_manager import ToolLabelManager from core.tools.utils.configuration import ToolParameterConfigurationManager from core.tools.utils.encryption import create_provider_encrypter, create_tool_provider_encrypter +from core.tools.utils.uuid_utils import is_valid_uuid +from core.tools.workflow_as_tool.provider import WorkflowToolProviderController from core.tools.workflow_as_tool.tool import WorkflowTool -from graphon.model_runtime.utils.encoders import jsonable_encoder +from extensions.ext_database import db +from graphon.runtime import VariablePool +from models.provider_ids import ToolProviderID from models.tools import ApiToolProvider, BuiltinToolProvider, WorkflowToolProvider +from services.tools.mcp_tools_manage_service import MCPToolManageService from services.tools.tools_transform_service import ToolTransformService if TYPE_CHECKING: @@ -921,23 +916,19 @@ def user_get_api_provider(cls, provider: str, tenant_id: str, mask: bool = True) # add tool labels labels = ToolLabelManager.get_tool_labels(controller) - - return cast( - dict, - jsonable_encoder( - { - "schema_type": provider_obj.schema_type, - "schema": provider_obj.schema, - "tools": provider_obj.tools, - "icon": icon, - "description": provider_obj.description, - "credentials": masked_credentials, - "privacy_policy": provider_obj.privacy_policy, - "custom_disclaimer": provider_obj.custom_disclaimer, - "labels": labels, - } - ), - ) + schema_type = provider_obj.schema_type + + return { + "schema_type": getattr(schema_type, "value", schema_type), + "schema": provider_obj.schema, + "tools": [tool.model_dump(mode="json") for tool in provider_obj.tools], + "icon": icon, + "description": provider_obj.description, + "credentials": masked_credentials, + "privacy_policy": provider_obj.privacy_policy, + "custom_disclaimer": provider_obj.custom_disclaimer, + "labels": labels, + } @classmethod def generate_builtin_tool_icon_url(cls, provider_id: str) -> str: diff --git a/api/dev/lint_response_contracts.py b/api/dev/lint_response_contracts.py index 75c5f67b8ff76d..77a2d2dc818cfe 100644 --- a/api/dev/lint_response_contracts.py +++ b/api/dev/lint_response_contracts.py @@ -2,8 +2,8 @@ This checker intentionally stays conservative. It only reports a hard schema mismatch when both sides are statically known for the same 2xx status code: -a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)`` -or ``Model.model_validate(...).model_dump()`` return. +a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)``, +``Model(...).model_dump()``, or ``Model.model_validate(...).model_dump()`` return. Raw dictionaries, raw lists, ``None`` responses, streaming helpers, missing response schemas, and returns with non-literal status codes are classified as @@ -28,6 +28,7 @@ HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put"} NO_BODY_STATUSES = {HTTPStatus.NO_CONTENT.value, HTTPStatus.RESET_CONTENT.value, HTTPStatus.NOT_MODIFIED.value} DEFAULT_CONTROLLER_DIRS = ("controllers/console", "controllers/service_api", "controllers/web") +IGNORE_COMMENT_MARKERS = ("response-contract:ignore",) type Classification = Literal["valid", "mismatch", "unknown", "refactorable"] type ActualKind = Literal[ @@ -41,6 +42,7 @@ "unknown", ] type MethodNode = ast.FunctionDef | ast.AsyncFunctionDef +type ModelValueSource = Literal["constructor", "model_validate"] HTTP_STATUS_NAMES = {status.name: status.value for status in HTTPStatus} HTTP_STATUS_NAMES.update({f"HTTP_{status.value}_{status.name}": status.value for status in HTTPStatus}) @@ -109,18 +111,22 @@ class VariableAssignmentSummary: """Track whether a local name is safe to treat as one specific response model.""" known_models: set[str] = field(default_factory=set) + known_sources: set[ModelValueSource] = field(default_factory=set) has_unknown_assignment: bool = False - def add_known(self, model: str) -> None: + def add_known(self, model: str, source: ModelValueSource) -> None: self.known_models.add(model) + self.known_sources.add(source) def add_unknown(self) -> None: self.has_unknown_assignment = True - def single_known_model(self) -> str | None: + def single_known_model(self) -> tuple[str, ModelValueSource] | None: if self.has_unknown_assignment or len(self.known_models) != 1: return None - return next(iter(self.known_models)) + model = next(iter(self.known_models)) + source: ModelValueSource = "constructor" if self.known_sources == {"constructor"} else "model_validate" + return model, source def dotted_name(node: ast.AST) -> str | None: @@ -249,6 +255,12 @@ def model_name_from_model_validate_call(node: ast.AST) -> str | None: return None +def model_value_from_model_validate_call(node: ast.AST) -> tuple[str, ModelValueSource] | None: + if model_name := model_name_from_model_validate_call(node): + return model_name, "model_validate" + return None + + def model_name_from_constructor_call(node: ast.AST) -> str | None: if not isinstance(node, ast.Call): return None @@ -257,6 +269,12 @@ def model_name_from_constructor_call(node: ast.AST) -> str | None: return None +def model_value_from_constructor_call(node: ast.AST) -> tuple[str, ModelValueSource] | None: + if model_name := model_name_from_constructor_call(node): + return model_name, "constructor" + return None + + def model_name_from_model_dump(node: ast.AST) -> str | None: if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute) or node.func.attr != "model_dump": return None @@ -272,6 +290,10 @@ def model_name_from_model_value(node: ast.AST) -> str | None: return model_name_from_model_validate_call(node) or model_name_from_constructor_call(node) +def model_value_from_model_value(node: ast.AST) -> tuple[str, ModelValueSource] | None: + return model_value_from_model_validate_call(node) or model_value_from_constructor_call(node) + + def model_name_from_dump_response(node: ast.AST) -> str | None: if not isinstance(node, ast.Call): return None @@ -287,7 +309,7 @@ def model_name_from_dump_response(node: ast.AST) -> str | None: def actual_kind_from_expr( - expr: ast.AST | None, variable_models: dict[str, str] | None = None + expr: ast.AST | None, variable_models: dict[str, tuple[str, ModelValueSource]] | None = None ) -> tuple[ActualKind, str | None]: if expr is None: return "none", None @@ -299,10 +321,14 @@ def actual_kind_from_expr( if isinstance(expr, ast.Call) and isinstance(expr.func, ast.Attribute) and expr.func.attr == "model_dump": dumped_value = expr.func.value if isinstance(dumped_value, ast.Name) and variable_models: - # A variable dump can match today, but it bypasses dump_response and - # is easier to drift; keep it visible as refactorable. - model_name = variable_models.get(dumped_value.id) - if model_name: + model_assignment = variable_models.get(dumped_value.id) + if model_assignment: + model_name, source = model_assignment + if source == "constructor": + return "model", model_name + # A variable dump from model_validate can match today, but it + # bypasses dump_response and is easier to drift; keep it visible + # as refactorable. return "model_dump_variable", model_name model_dump_model = model_name_from_model_dump(expr) @@ -325,7 +351,9 @@ def actual_kind_from_expr( return "unknown", None -def actual_response_from_return(return_node: ast.Return, variable_models: dict[str, str]) -> ActualResponse: +def actual_response_from_return( + return_node: ast.Return, variable_models: dict[str, tuple[str, ModelValueSource]] +) -> ActualResponse: status: int | None = 200 body_expr = return_node.value @@ -363,18 +391,21 @@ def target_names(target: ast.AST) -> Iterable[str]: def record_assignment( - assignments: defaultdict[str, VariableAssignmentSummary], targets: Iterable[str], model_name: str | None + assignments: defaultdict[str, VariableAssignmentSummary], + targets: Iterable[str], + model_assignment: tuple[str, ModelValueSource] | None, ) -> None: for target in targets: - if model_name is None: + if model_assignment is None: # Once a name receives an unknown value, later model_dump() calls on it # are no longer a reliable signal for the returned schema. assignments[target].add_unknown() else: - assignments[target].add_known(model_name) + model_name, source = model_assignment + assignments[target].add_known(model_name, source) -def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]: +def variable_model_assignments_for_method(method: MethodNode) -> dict[str, tuple[str, ModelValueSource]]: """Infer local variables that are unambiguously assigned one response model.""" assignments: defaultdict[str, VariableAssignmentSummary] = defaultdict(VariableAssignmentSummary) @@ -385,10 +416,10 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]: record_assignment( assignments, (name for target in targets for name in target_names(target)), - model_name_from_model_value(value), + model_value_from_model_value(value), ) case ast.AnnAssign(target=target, value=value) if value is not None: - record_assignment(assignments, target_names(target), model_name_from_model_value(value)) + record_assignment(assignments, target_names(target), model_value_from_model_value(value)) case ast.AugAssign(target=target) | ast.For(target=target) | ast.AsyncFor(target=target): # Mutation and loop targets overwrite prior values with runtime-dependent data. record_assignment(assignments, target_names(target), None) @@ -399,9 +430,13 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]: case ast.ExceptHandler(name=name) if name: assignments[name].add_unknown() case ast.NamedExpr(target=target, value=value): - record_assignment(assignments, target_names(target), model_name_from_model_value(value)) + record_assignment(assignments, target_names(target), model_value_from_model_value(value)) - return {name: model for name, summary in assignments.items() if (model := summary.single_known_model()) is not None} + return { + name: assignment + for name, summary in assignments.items() + if (assignment := summary.single_known_model()) is not None + } def actual_responses_for_method(method: MethodNode) -> list[ActualResponse]: @@ -545,13 +580,52 @@ def iter_controller_files(paths: Iterable[Path]) -> Iterable[Path]: yield from sorted(child for child in path.rglob("*.py") if child.is_file()) +def node_start_lineno(node: ast.ClassDef | MethodNode) -> int: + decorator_lines = [decorator.lineno for decorator in node.decorator_list] + if decorator_lines: + return min(decorator_lines) + return node.lineno + + +def line_has_ignore_marker(line: str) -> bool: + _, marker, comment = line.partition("#") + if not marker: + return False + normalized = comment.lower() + return any(ignore_marker in normalized for ignore_marker in IGNORE_COMMENT_MARKERS) + + +def node_has_ignore_comment(lines: Sequence[str], node: ast.ClassDef | MethodNode) -> bool: + start = node_start_lineno(node) + end = node.end_lineno or node.lineno + if any(line_has_ignore_marker(line) for line in lines[start - 1 : end]): + return True + + line_index = start - 2 + while line_index >= 0: + stripped = lines[line_index].strip() + if not stripped: + line_index -= 1 + continue + if not stripped.startswith("#"): + break + if line_has_ignore_marker(lines[line_index]): + return True + line_index -= 1 + return False + + def checks_for_file(file_path: Path, repo_root: Path) -> list[ContractCheck]: - module = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path)) + source = file_path.read_text(encoding="utf-8") + lines = source.splitlines() + module = ast.parse(source, filename=str(file_path)) checks: list[ContractCheck] = [] for node in module.body: if not isinstance(node, ast.ClassDef): continue + if node_has_ignore_comment(lines, node): + continue class_routes = routes_from_decorators(node.decorator_list) class_documented = response_docs_from_decorators(node.decorator_list) @@ -559,6 +633,8 @@ def checks_for_file(file_path: Path, repo_root: Path) -> list[ContractCheck]: for item in node.body: if not isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef) or item.name not in HTTP_METHODS: continue + if node_has_ignore_comment(lines, item): + continue routes = routes_from_decorators(item.decorator_list) or class_routes if not routes: diff --git a/api/fields/annotation_fields.py b/api/fields/annotation_fields.py index 4546a051cce1ea..86a13a32bd2dd5 100644 --- a/api/fields/annotation_fields.py +++ b/api/fields/annotation_fields.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime +from typing import Literal from pydantic import Field, field_validator @@ -29,6 +30,15 @@ class AnnotationList(ResponseModel): page: int +class AnnotationJobStatusResponse(ResponseModel): + job_id: str + job_status: Literal["waiting", "processing", "completed", "error"] | str + + +class AnnotationJobStatusDetailResponse(AnnotationJobStatusResponse): + error_msg: str = "" + + class AnnotationExportList(ResponseModel): data: list[Annotation] diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py deleted file mode 100644 index 96d8fbdf34cbb0..00000000000000 --- a/api/fields/app_fields.py +++ /dev/null @@ -1,271 +0,0 @@ -import json -from typing import override - -from flask_restx import fields - -from fields.workflow_fields import workflow_partial_fields -from libs.helper import AppIconUrlField, TimestampField - - -class JsonStringField(fields.Raw): - @override - def format(self, value): - if isinstance(value, str): - try: - return json.loads(value) - except (json.JSONDecodeError, TypeError): - return value - return value - - -class OpaqueRawField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return {"type": "object"} - - -class StringListRawField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return {"type": "array", "items": {"type": "string"}} - - -class ObjectListRawField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return {"type": "array", "items": {"type": "object"}} - - -app_detail_kernel_fields = { - "id": fields.String, - "name": fields.String, - "description": fields.String, - "mode": fields.String(attribute="mode_compatible_with_agent"), - "icon_type": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "icon_url": AppIconUrlField, -} - -related_app_list = { - "data": fields.List(fields.Nested(app_detail_kernel_fields)), - "total": fields.Integer, -} - -model_config_fields = { - "opening_statement": fields.String, - "suggested_questions": StringListRawField(attribute="suggested_questions_list"), - "suggested_questions_after_answer": OpaqueRawField(attribute="suggested_questions_after_answer_dict"), - "speech_to_text": OpaqueRawField(attribute="speech_to_text_dict"), - "text_to_speech": OpaqueRawField(attribute="text_to_speech_dict"), - "retriever_resource": OpaqueRawField(attribute="retriever_resource_dict"), - "annotation_reply": OpaqueRawField(attribute="annotation_reply_dict"), - "more_like_this": OpaqueRawField(attribute="more_like_this_dict"), - "sensitive_word_avoidance": OpaqueRawField(attribute="sensitive_word_avoidance_dict"), - "external_data_tools": ObjectListRawField(attribute="external_data_tools_list"), - "model": OpaqueRawField(attribute="model_dict"), - "user_input_form": ObjectListRawField(attribute="user_input_form_list"), - "dataset_query_variable": fields.String, - "pre_prompt": fields.String, - "agent_mode": OpaqueRawField(attribute="agent_mode_dict"), - "prompt_type": fields.String, - "chat_prompt_config": OpaqueRawField(attribute="chat_prompt_config_dict"), - "completion_prompt_config": OpaqueRawField(attribute="completion_prompt_config_dict"), - "dataset_configs": OpaqueRawField(attribute="dataset_configs_dict"), - "file_upload": OpaqueRawField(attribute="file_upload_dict"), - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, -} - -tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String} - -app_detail_fields = { - "id": fields.String, - "name": fields.String, - "description": fields.String, - "mode": fields.String(attribute="mode_compatible_with_agent"), - "icon": fields.String, - "icon_background": fields.String, - "enable_site": fields.Boolean, - "enable_api": fields.Boolean, - "model_config": fields.Nested(model_config_fields, attribute="app_model_config", allow_null=True), - "workflow": fields.Nested(workflow_partial_fields, allow_null=True), - "tracing": OpaqueRawField, - "use_icon_as_answer_icon": fields.Boolean, - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, - "access_mode": fields.String, - "tags": fields.List(fields.Nested(tag_fields)), - "permission_keys": fields.List(fields.String()), -} - -prompt_config_fields = { - "prompt_template": fields.String, -} - -model_config_partial_fields = { - "model": OpaqueRawField(attribute="model_dict"), - "pre_prompt": fields.String, - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, -} - -app_partial_fields = { - "id": fields.String, - "name": fields.String, - "max_active_requests": OpaqueRawField(), - "description": fields.String(attribute="desc_or_prompt"), - "mode": fields.String(attribute="mode_compatible_with_agent"), - "icon_type": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "icon_url": AppIconUrlField, - "model_config": fields.Nested(model_config_partial_fields, attribute="app_model_config", allow_null=True), - "workflow": fields.Nested(workflow_partial_fields, allow_null=True), - "use_icon_as_answer_icon": fields.Boolean, - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, - "tags": fields.List(fields.Nested(tag_fields)), - "access_mode": fields.String, - "create_user_name": fields.String, - "author_name": fields.String, - "has_draft_trigger": fields.Boolean, - "permission_keys": fields.List(fields.String()), -} - - -app_pagination_fields = { - "page": fields.Integer, - "limit": fields.Integer(attribute="per_page"), - "total": fields.Integer, - "has_more": fields.Boolean(attribute="has_next"), - "data": fields.List(fields.Nested(app_partial_fields), attribute="items"), -} - -template_fields = { - "name": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "description": fields.String, - "mode": fields.String, - "model_config": fields.Nested(model_config_fields), -} - -template_list_fields = { - "data": fields.List(fields.Nested(template_fields)), -} - -site_fields = { - "access_token": fields.String(attribute="code"), - "code": fields.String, - "title": fields.String, - "icon_type": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "icon_url": AppIconUrlField, - "description": fields.String, - "default_language": fields.String, - "chat_color_theme": fields.String, - "chat_color_theme_inverted": fields.Boolean, - "customize_domain": fields.String, - "copyright": fields.String, - "privacy_policy": fields.String, - "custom_disclaimer": fields.String, - "customize_token_strategy": fields.String, - "prompt_public": fields.Boolean, - "app_base_url": fields.String, - "show_workflow_steps": fields.Boolean, - "use_icon_as_answer_icon": fields.Boolean, - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, -} - -deleted_tool_fields = { - "type": fields.String, - "tool_name": fields.String, - "provider_id": fields.String, -} - -app_detail_fields_with_site = { - "id": fields.String, - "name": fields.String, - "description": fields.String, - "mode": fields.String(attribute="mode_compatible_with_agent"), - "icon_type": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "icon_url": AppIconUrlField, - "enable_site": fields.Boolean, - "enable_api": fields.Boolean, - "model_config": fields.Nested(model_config_fields, attribute="app_model_config", allow_null=True), - "workflow": fields.Nested(workflow_partial_fields, allow_null=True), - "api_base_url": fields.String, - "use_icon_as_answer_icon": fields.Boolean, - "max_active_requests": fields.Integer, - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, - "deleted_tools": fields.List(fields.Nested(deleted_tool_fields)), - "access_mode": fields.String, - "tags": fields.List(fields.Nested(tag_fields)), - "permission_keys": fields.List(fields.String()), - "site": fields.Nested(site_fields), -} - - -app_site_fields = { - "app_id": fields.String, - "access_token": fields.String(attribute="code"), - "code": fields.String, - "title": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "description": fields.String, - "default_language": fields.String, - "customize_domain": fields.String, - "copyright": fields.String, - "privacy_policy": fields.String, - "custom_disclaimer": fields.String, - "customize_token_strategy": fields.String, - "prompt_public": fields.Boolean, - "show_workflow_steps": fields.Boolean, - "use_icon_as_answer_icon": fields.Boolean, -} - -leaked_dependency_fields = {"type": fields.String, "value": OpaqueRawField, "current_identifier": fields.String} - -app_import_fields = { - "id": fields.String, - "status": fields.String, - "app_id": fields.String, - "app_mode": fields.String, - "current_dsl_version": fields.String, - "imported_dsl_version": fields.String, - "error": fields.String, -} - -app_import_check_dependencies_fields = { - "leaked_dependencies": fields.List(fields.Nested(leaked_dependency_fields)), -} - -app_server_fields = { - "id": fields.String, - "name": fields.String, - "server_code": fields.String, - "description": fields.String, - "status": fields.String, - "parameters": JsonStringField, - "created_at": TimestampField, - "updated_at": TimestampField, -} diff --git a/api/fields/base.py b/api/fields/base.py index b806ab6c9c86d8..826a07d00ea2df 100644 --- a/api/fields/base.py +++ b/api/fields/base.py @@ -7,7 +7,8 @@ class ResponseModel(BaseModel): model_config = ConfigDict( from_attributes=True, extra="ignore", - populate_by_name=True, + validate_by_name=True, + validate_by_alias=True, serialize_by_alias=True, protected_namespaces=(), ) diff --git a/api/fields/conversation_variable_fields.py b/api/fields/conversation_variable_fields.py index 4d5de84fd98abf..6618475f2f3fa2 100644 --- a/api/fields/conversation_variable_fields.py +++ b/api/fields/conversation_variable_fields.py @@ -3,25 +3,14 @@ from datetime import datetime from typing import Any -from flask_restx import fields from pydantic import field_validator from fields.base import ResponseModel from graphon.variables.types import SegmentType -from libs.helper import TimestampField, to_timestamp +from libs.helper import to_timestamp from ._value_type_serializer import serialize_value_type -conversation_variable_fields = { - "id": fields.String, - "name": fields.String, - "value_type": fields.String(attribute=serialize_value_type), - "value": fields.String, - "description": fields.String, - "created_at": TimestampField, - "updated_at": TimestampField, -} - class ConversationVariableResponse(ResponseModel): id: str diff --git a/api/fields/dataset_fields.py b/api/fields/dataset_fields.py index ea506d2a7e4811..f97f5b79460287 100644 --- a/api/fields/dataset_fields.py +++ b/api/fields/dataset_fields.py @@ -1,22 +1,9 @@ from datetime import datetime -from flask_restx import fields from pydantic import Field, field_validator from fields.base import ResponseModel -from libs.helper import TimestampField, to_timestamp - -dataset_fields = { - "id": fields.String, - "name": fields.String, - "description": fields.String, - "permission": fields.String, - "data_source_type": fields.String, - "indexing_technique": fields.String, - "created_by": fields.String, - "created_at": TimestampField, - "permission_keys": fields.List(fields.String()), -} +from libs.helper import to_timestamp class DatasetMetadataResponse(ResponseModel): @@ -50,104 +37,6 @@ class DatasetMetadataActionResponse(ResponseModel): result: str -reranking_model_fields = {"reranking_provider_name": fields.String, "reranking_model_name": fields.String} - -keyword_setting_fields = {"keyword_weight": fields.Float} - -vector_setting_fields = { - "vector_weight": fields.Float, - "embedding_model_name": fields.String, - "embedding_provider_name": fields.String, -} - -weighted_score_fields = { - "weight_type": fields.String, - "keyword_setting": fields.Nested(keyword_setting_fields), - "vector_setting": fields.Nested(vector_setting_fields), -} - -dataset_retrieval_model_fields = { - "search_method": fields.String, - "reranking_enable": fields.Boolean, - "reranking_mode": fields.String, - "reranking_model": fields.Nested(reranking_model_fields), - "weights": fields.Nested(weighted_score_fields, allow_null=True), - "top_k": fields.Integer, - "score_threshold_enabled": fields.Boolean, - "score_threshold": fields.Float, -} - -dataset_summary_index_fields = { - "enable": fields.Boolean, - "model_name": fields.String, - "model_provider_name": fields.String, - "summary_prompt": fields.String, -} - -external_retrieval_model_fields = { - "top_k": fields.Integer, - "score_threshold": fields.Float, - "score_threshold_enabled": fields.Boolean, -} - -tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String} - -external_knowledge_info_fields = { - "external_knowledge_id": fields.String, - "external_knowledge_api_id": fields.String, - "external_knowledge_api_name": fields.String, - "external_knowledge_api_endpoint": fields.String, -} - -doc_metadata_fields = {"id": fields.String, "name": fields.String, "type": fields.String} - -icon_info_fields = { - "icon_type": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "icon_url": fields.String, -} - -dataset_detail_fields = { - "id": fields.String, - "name": fields.String, - "description": fields.String, - "provider": fields.String, - "permission": fields.String, - "data_source_type": fields.String, - "indexing_technique": fields.String, - "app_count": fields.Integer, - "document_count": fields.Integer, - "word_count": fields.Integer, - "created_by": fields.String, - "author_name": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, - "embedding_model": fields.String, - "embedding_model_provider": fields.String, - "embedding_available": fields.Boolean, - "retrieval_model_dict": fields.Nested(dataset_retrieval_model_fields), - "summary_index_setting": fields.Nested(dataset_summary_index_fields), - "tags": fields.List(fields.Nested(tag_fields)), - "doc_form": fields.String, - "external_knowledge_info": fields.Nested(external_knowledge_info_fields), - "external_retrieval_model": fields.Nested(external_retrieval_model_fields, allow_null=True), - "doc_metadata": fields.List(fields.Nested(doc_metadata_fields)), - "built_in_field_enabled": fields.Boolean, - "pipeline_id": fields.String, - "runtime_mode": fields.String, - "chunk_structure": fields.String, - "icon_info": fields.Nested(icon_info_fields), - "is_published": fields.Boolean, - "total_documents": fields.Integer, - "total_available_documents": fields.Integer, - "enable_api": fields.Boolean, - "is_multimodal": fields.Boolean, - "permission_keys": fields.List(fields.String()), -} - - class DatasetRerankingModelResponse(ResponseModel): reranking_provider_name: str | None = None reranking_model_name: str | None = None diff --git a/api/fields/file_fields.py b/api/fields/file_fields.py index 1cd6e8af6a2e13..681dc3db2d2e02 100644 --- a/api/fields/file_fields.py +++ b/api/fields/file_fields.py @@ -11,14 +11,14 @@ class UploadConfig(ResponseModel): file_size_limit: int batch_count_limit: int - file_upload_limit: int | None = None + file_upload_limit: int image_file_size_limit: int video_file_size_limit: int audio_file_size_limit: int workflow_file_upload_limit: int image_file_batch_limit: int single_chunk_attachment_limit: int - attachment_image_file_size_limit: int | None = None + attachment_image_file_size_limit: int class FileResponse(ResponseModel): diff --git a/api/fields/member_fields.py b/api/fields/member_fields.py index 9bbcbef8429257..5108522af01400 100644 --- a/api/fields/member_fields.py +++ b/api/fields/member_fields.py @@ -2,26 +2,19 @@ from datetime import datetime -from flask_restx import fields from pydantic import Field, computed_field, field_validator from fields.base import ResponseModel from libs.helper import build_avatar_url, to_timestamp -simple_account_fields = { - "id": fields.String, - "name": fields.String, - "email": fields.String, -} - -class SimpleAccount(ResponseModel): +class SimpleAccountResponse(ResponseModel): id: str name: str email: str -class _AccountAvatar(ResponseModel): +class _AccountAvatarResponseMixin(ResponseModel): avatar: str | None = None @computed_field(return_type=str | None) # type: ignore[prop-decorator] @@ -30,7 +23,7 @@ def avatar_url(self) -> str | None: return build_avatar_url(self.avatar) -class Account(_AccountAvatar): +class AccountResponse(_AccountAvatarResponseMixin): id: str name: str email: str @@ -48,7 +41,7 @@ def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) -class AccountWithRole(_AccountAvatar): +class AccountWithRoleResponse(_AccountAvatarResponseMixin): id: str name: str email: str @@ -65,5 +58,11 @@ def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) -class AccountWithRoleList(ResponseModel): - accounts: list[AccountWithRole] +class AccountWithRoleListResponse(ResponseModel): + accounts: list[AccountWithRoleResponse] + + +SimpleAccount = SimpleAccountResponse +Account = AccountResponse +AccountWithRole = AccountWithRoleResponse +AccountWithRoleList = AccountWithRoleListResponse diff --git a/api/fields/raws.py b/api/fields/raws.py deleted file mode 100644 index c7e047626f1852..00000000000000 --- a/api/fields/raws.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import override - -from flask_restx import fields - -from graphon.file import File - - -class FilesContainedField(fields.Raw): - @override - def format(self, value): - return self._format_file_object(value) - - def _format_file_object(self, v): - if isinstance(v, File): - return v.model_dump() - if isinstance(v, dict): - return {k: self._format_file_object(vv) for k, vv in v.items()} - if isinstance(v, list): - return [self._format_file_object(vv) for vv in v] - return v diff --git a/api/fields/snippet_fields.py b/api/fields/snippet_fields.py index 699a3687ac17f3..9d12b600722c24 100644 --- a/api/fields/snippet_fields.py +++ b/api/fields/snippet_fields.py @@ -1,61 +1,67 @@ -from typing import override - -from flask_restx import fields - -from fields.member_fields import simple_account_fields -from libs.helper import TimestampField - - -class OpaqueRawField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return {"type": "object"} - - -tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String} - -# Snippet list item fields (lightweight for list display) -snippet_list_fields = { - "id": fields.String, - "name": fields.String, - "description": fields.String, - "type": fields.String, - "version": fields.Integer, - "use_count": fields.Integer, - "is_published": fields.Boolean, - "icon_info": OpaqueRawField, - "tags": fields.List(fields.Nested(tag_fields)), - "created_by": fields.String, - "author_name": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, -} - -# Full snippet fields (includes creator info and graph data) -snippet_fields = { - "id": fields.String, - "name": fields.String, - "description": fields.String, - "type": fields.String, - "version": fields.Integer, - "use_count": fields.Integer, - "is_published": fields.Boolean, - "icon_info": OpaqueRawField, - "graph": OpaqueRawField(attribute="graph_dict"), - "input_fields": OpaqueRawField(attribute="input_fields_list"), - "tags": fields.List(fields.Nested(tag_fields)), - "created_by": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), - "created_at": TimestampField, - "updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True), - "updated_at": TimestampField, -} - -# Pagination response fields -snippet_pagination_fields = { - "data": fields.List(fields.Nested(snippet_list_fields)), - "page": fields.Integer, - "limit": fields.Integer, - "total": fields.Integer, - "has_more": fields.Boolean, -} +from datetime import datetime +from typing import Any + +from pydantic import Field, field_validator + +from fields.base import ResponseModel +from fields.member_fields import SimpleAccountResponse +from libs.helper import to_timestamp + + +class SnippetTagResponse(ResponseModel): + id: str + name: str + type: str + + +class SnippetListItemResponse(ResponseModel): + id: str + name: str + description: str | None = None + type: str + version: int + use_count: int + is_published: bool + icon_info: dict[str, Any] | None = None + tags: list[SnippetTagResponse] = Field(default_factory=list) + created_by: str | None = None + author_name: str | None = None + created_at: int | None = None + updated_by: str | None = None + updated_at: int | None = None + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class SnippetResponse(ResponseModel): + id: str + name: str + description: str | None = None + type: str + version: int + use_count: int + is_published: bool + icon_info: dict[str, Any] | None = None + graph: dict[str, Any] | None = Field(default=None, validation_alias="graph_dict") + input_fields: list[dict[str, Any]] | None = Field(default=None, validation_alias="input_fields_list") + tags: list[SnippetTagResponse] = Field(default_factory=list) + created_by: SimpleAccountResponse | None = Field(default=None, validation_alias="created_by_account") + created_at: int | None = None + updated_by: SimpleAccountResponse | None = Field(default=None, validation_alias="updated_by_account") + updated_at: int | None = None + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class SnippetPaginationResponse(ResponseModel): + data: list[SnippetListItemResponse] + page: int + limit: int + total: int + has_more: bool diff --git a/api/fields/workflow_fields.py b/api/fields/workflow_fields.py deleted file mode 100644 index 2d0d8f9f54619e..00000000000000 --- a/api/fields/workflow_fields.py +++ /dev/null @@ -1,129 +0,0 @@ -from typing import override - -from flask_restx import fields - -from core.helper import encrypter -from fields.member_fields import simple_account_fields -from graphon.variables import SecretVariable, SegmentType, VariableBase -from libs.helper import TimestampField - -from ._value_type_serializer import serialize_value_type - -ENVIRONMENT_VARIABLE_SUPPORTED_TYPES = (SegmentType.STRING, SegmentType.NUMBER, SegmentType.SECRET) - - -class OpaqueRawField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return {"type": "object"} - - -class JsonValueRawField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return { - "anyOf": [ - {"type": "string"}, - {"type": "integer"}, - {"type": "number"}, - {"type": "boolean"}, - {"type": "object", "additionalProperties": True}, - {"type": "array", "items": {}}, - {"type": "null"}, - ] - } - - -class EnvironmentVariableField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return {"type": "object"} - - @override - def format(self, value): - # Mask secret variables values in environment_variables - if isinstance(value, SecretVariable): - return { - "id": value.id, - "name": value.name, - "value": encrypter.full_mask_token(), - "value_type": value.value_type.value, - "description": value.description, - } - if isinstance(value, VariableBase): - return { - "id": value.id, - "name": value.name, - "value": value.value, - "value_type": str(value.value_type.exposed_type()), - "description": value.description, - } - if isinstance(value, dict): - value_type_str = value.get("value_type") - if not isinstance(value_type_str, str): - raise TypeError( - f"unexpected type for value_type field, value={value_type_str}, type={type(value_type_str)}" - ) - value_type = SegmentType(value_type_str).exposed_type() - if value_type not in ENVIRONMENT_VARIABLE_SUPPORTED_TYPES: - raise ValueError(f"Unsupported environment variable value type: {value_type}") - return value - - -conversation_variable_fields = { - "id": fields.String, - "name": fields.String, - "value_type": fields.String(attribute=serialize_value_type), - "value": JsonValueRawField, - "description": fields.String, -} - -pipeline_variable_fields = { - "label": fields.String, - "variable": fields.String, - "type": fields.String, - "belong_to_node_id": fields.String, - "max_length": fields.Integer, - "required": fields.Boolean, - "unit": fields.String, - "default_value": JsonValueRawField, - "options": fields.List(fields.String), - "placeholder": fields.String, - "tooltips": fields.String, - "allowed_file_types": fields.List(fields.String), - "allow_file_extension": fields.List(fields.String), - "allow_file_upload_methods": fields.List(fields.String), -} - -workflow_fields = { - "id": fields.String, - "graph": OpaqueRawField(attribute="graph_dict"), - "features": OpaqueRawField(attribute="features_dict"), - "hash": fields.String(attribute="unique_hash"), - "version": fields.String, - "marked_name": fields.String, - "marked_comment": fields.String, - "created_by": fields.Nested(simple_account_fields, attribute="created_by_account"), - "created_at": TimestampField, - "updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True), - "updated_at": TimestampField, - "tool_published": fields.Boolean, - "environment_variables": fields.List(EnvironmentVariableField()), - "conversation_variables": fields.List(fields.Nested(conversation_variable_fields)), - "rag_pipeline_variables": fields.List(fields.Nested(pipeline_variable_fields)), -} - -workflow_partial_fields = { - "id": fields.String, - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, -} - -workflow_pagination_fields = { - "items": fields.List(fields.Nested(workflow_fields), attribute="items"), - "page": fields.Integer, - "limit": fields.Integer(attribute="limit"), - "has_more": fields.Boolean(attribute="has_more"), -} diff --git a/api/fields/workflow_run_fields.py b/api/fields/workflow_run_fields.py index 53cdfa234f999b..be043fbbd6c99a 100644 --- a/api/fields/workflow_run_fields.py +++ b/api/fields/workflow_run_fields.py @@ -1,53 +1,14 @@ -"""Workflow run response schemas for console APIs. - -Most workflow-run endpoints should document and serialize responses with the -Pydantic models in this module. The remaining Flask-RESTX field dictionaries are -kept only for workflow app-log endpoints that still build legacy log models. -""" - from __future__ import annotations from datetime import datetime from typing import Any -from flask_restx import Namespace, fields from pydantic import AliasChoices, Field, field_validator from fields.base import ResponseModel from fields.end_user_fields import SimpleEndUser -from fields.member_fields import SimpleAccount -from libs.helper import TimestampField, to_timestamp - -workflow_run_for_log_fields = { - "id": fields.String, - "version": fields.String, - "status": fields.String, - "triggered_from": fields.String, - "error": fields.String, - "elapsed_time": fields.Float, - "total_tokens": fields.Integer, - "total_steps": fields.Integer, - "created_at": TimestampField, - "finished_at": TimestampField, - "exceptions_count": fields.Integer, -} - - -def build_workflow_run_for_log_model(api_or_ns: Namespace): - return api_or_ns.model("WorkflowRunForLog", workflow_run_for_log_fields) - - -workflow_run_for_archived_log_fields = { - "id": fields.String, - "status": fields.String, - "triggered_from": fields.String, - "elapsed_time": fields.Float, - "total_tokens": fields.Integer, -} - - -def build_workflow_run_for_archived_log_model(api_or_ns: Namespace): - return api_or_ns.model("WorkflowRunForArchivedLog", workflow_run_for_archived_log_fields) +from fields.member_fields import SimpleAccountResponse +from libs.helper import to_timestamp class WorkflowRunForLogResponse(ResponseModel): @@ -98,7 +59,7 @@ class WorkflowRunForListResponse(ResponseModel): elapsed_time: float | None = None total_tokens: int | None = None total_steps: int | None = None - created_by_account: SimpleAccount | None = None + created_by_account: SimpleAccountResponse | None = None created_at: int | None = None finished_at: int | None = None exceptions_count: int | None = None @@ -158,7 +119,7 @@ class WorkflowRunDetailResponse(ResponseModel): total_tokens: int | None = None total_steps: int | None = None created_by_role: str | None = None - created_by_account: SimpleAccount | None = None + created_by_account: SimpleAccountResponse | None = None created_by_end_user: SimpleEndUser | None = None created_at: int | None = None finished_at: int | None = None @@ -194,7 +155,7 @@ class WorkflowRunNodeExecutionResponse(ResponseModel): extras: Any = None created_at: int | None = None created_by_role: str | None = None - created_by_account: SimpleAccount | None = None + created_by_account: SimpleAccountResponse | None = None created_by_end_user: SimpleEndUser | None = None finished_at: int | None = None inputs_truncated: bool | None = None diff --git a/api/libs/helper.py b/api/libs/helper.py index 7066f9eab45d08..35c613ae90e509 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -10,12 +10,11 @@ from collections.abc import Callable, Generator, Mapping from datetime import datetime from hashlib import sha256 -from typing import TYPE_CHECKING, Annotated, Any, Protocol, cast, overload, override +from typing import TYPE_CHECKING, Annotated, Any, Protocol, cast, overload from uuid import UUID from zoneinfo import available_timezones from flask import Request, Response, stream_with_context -from flask_restx import fields from pydantic import BaseModel, ConfigDict, TypeAdapter, with_config from pydantic.functional_validators import AfterValidator from typing_extensions import TypedDict @@ -127,26 +126,6 @@ def run(script): return subprocess.getstatusoutput("source /root/.bashrc && " + script) -class AppIconUrlField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return {"type": "string", "nullable": True} - - @override - def output(self, key, obj, **kwargs): - if obj is None: - return None - - from models.model import App, IconType, Site - - if isinstance(obj, dict) and "app" in obj: - obj = obj["app"] - - if isinstance(obj, App | Site) and obj.icon_type == IconType.IMAGE: - return build_icon_url(obj.icon_type, obj.icon) - return None - - def build_icon_url(icon_type: Any, icon: str | None) -> str | None: if icon is None or icon_type is None: return None @@ -167,28 +146,6 @@ def build_avatar_url(avatar: str | None) -> str | None: return file_helpers.get_signed_file_url(avatar) -class TimestampField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return {"type": "integer", "format": "int64"} - - @override - def format(self, value) -> int: - return int(value.timestamp()) - - -class OptionalTimestampField(fields.Raw): - @override - def schema(self) -> dict[str, object]: - return {"type": "integer", "format": "int64", "nullable": True} - - @override - def format(self, value) -> int | None: - if value is None: - return None - return int(value.timestamp()) - - @overload def to_timestamp(value: datetime) -> int: ... diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index b3a0b8a6a7111a..4df76a04c981d9 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -38,7 +38,7 @@ Get account avatar url | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [Account](#account)
| +| 200 | Success | **application/json**: [AccountResponse](#accountresponse)
| ### [POST] /account/change-email #### Request Body @@ -77,7 +77,7 @@ Get account avatar url | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [Account](#account)
| +| 200 | Success | **application/json**: [AccountResponse](#accountresponse)
| ### [POST] /account/change-email/validity #### Request Body @@ -141,9 +141,9 @@ Get account avatar url #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [EducationActivateResponse](#educationactivateresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [GET] /account/education/autocomplete #### Parameters @@ -198,7 +198,7 @@ Get account avatar url | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [Account](#account)
| +| 200 | Success | **application/json**: [AccountResponse](#accountresponse)
| ### [POST] /account/interface-theme #### Request Body @@ -211,7 +211,7 @@ Get account avatar url | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [Account](#account)
| +| 200 | Success | **application/json**: [AccountResponse](#accountresponse)
| ### [POST] /account/name #### Request Body @@ -224,7 +224,7 @@ Get account avatar url | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [Account](#account)
| +| 200 | Success | **application/json**: [AccountResponse](#accountresponse)
| ### [POST] /account/password #### Request Body @@ -237,14 +237,14 @@ Get account avatar url | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [Account](#account)
| +| 200 | Success | **application/json**: [AccountResponse](#accountresponse)
| ### [GET] /account/profile #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [Account](#account)
| +| 200 | Success | **application/json**: [AccountResponse](#accountresponse)
| ### [POST] /account/timezone #### Request Body @@ -257,7 +257,7 @@ Get account avatar url | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [Account](#account)
| +| 200 | Success | **application/json**: [AccountResponse](#accountresponse)
| ### [POST] /activate Activate account with invitation token @@ -1050,7 +1050,7 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [WorkspaceListResponse](#workspacelistresponse)
| +| 200 | Success | **application/json**: [WorkspacePaginationResponse](#workspacepaginationresponse)
| ### [GET] /api-based-extension Get all API-based extensions for current tenant @@ -1433,7 +1433,7 @@ Get human input form preview for advanced chat workflow | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Human input form preview | **application/json**: [HumanInputFormPreviewResponse](#humaninputformpreviewresponse)
| +| 200 | Human input form preview retrieved | **application/json**: [HumanInputFormPreviewResponse](#humaninputformpreviewresponse)
| ### [POST] /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/run **Submit human input form preview** @@ -1455,9 +1455,9 @@ Submit human input form preview for advanced chat workflow #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Human input form submission result | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Human input form submitted | ### [POST] /apps/{app_id}/advanced-chat/workflows/draft/iteration/nodes/{node_id}/run **Run draft workflow iteration node** @@ -1479,11 +1479,11 @@ Run draft workflow iteration node for advanced chat #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Iteration node run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 403 | Permission denied | | -| 404 | Node not found | | +| Code | Description | +| ---- | ----------- | +| 200 | Iteration node run started successfully | +| 403 | Permission denied | +| 404 | Node not found | ### [POST] /apps/{app_id}/advanced-chat/workflows/draft/loop/nodes/{node_id}/run **Run draft workflow loop node** @@ -1505,11 +1505,11 @@ Run draft workflow loop node for advanced chat #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Loop node run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 403 | Permission denied | | -| 404 | Node not found | | +| Code | Description | +| ---- | ----------- | +| 200 | Loop node run started successfully | +| 403 | Permission denied | +| 404 | Node not found | ### [POST] /apps/{app_id}/advanced-chat/workflows/draft/run **Run draft workflow** @@ -1530,11 +1530,11 @@ Run draft workflow for advanced chat application #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Invalid request parameters | | -| 403 | Permission denied | | +| Code | Description | +| ---- | ----------- | +| 200 | Workflow run started successfully | +| 400 | Invalid request parameters | +| 403 | Permission denied | ### [GET] /apps/{app_id}/agent/drive/files List agent drive entries (read-only inspector; one endpoint for both tabs) @@ -1782,7 +1782,7 @@ Get status of annotation reply action job | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Job status retrieved successfully | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| +| 200 | Job status retrieved successfully | **application/json**: [AnnotationJobStatusDetailResponse](#annotationjobstatusdetailresponse)
| | 403 | Insufficient permissions | | ### [GET] /apps/{app_id}/annotation-setting @@ -1891,7 +1891,7 @@ Batch import annotations from CSV file with rate limiting and security checks | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Batch import started successfully | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| +| 200 | Batch import started successfully | **application/json**: [AnnotationBatchImportResponse](#annotationbatchimportresponse)
| | 400 | No file uploaded or too many files | | | 403 | Insufficient permissions | | | 413 | File too large | | @@ -1911,7 +1911,7 @@ Get status of batch import job | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Job status retrieved successfully | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| +| 200 | Job status retrieved successfully | **application/json**: [AnnotationJobStatusDetailResponse](#annotationjobstatusdetailresponse)
| | 403 | Insufficient permissions | | ### [GET] /apps/{app_id}/annotations/count @@ -2227,11 +2227,11 @@ Generate completion message for debugging #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Completion generated successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Invalid request parameters | | -| 404 | App not found | | +| Code | Description | +| ---- | ----------- | +| 200 | Completion generated successfully | +| 400 | Invalid request parameters | +| 404 | App not found | ### [POST] /apps/{app_id}/completion-messages/{task_id}/stop Stop a running completion message generation @@ -2314,6 +2314,7 @@ Create a copy of an existing application | Code | Description | Schema | | ---- | ----------- | ------ | | 201 | App copied successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| +| 202 | App copy requires confirmation | **application/json**: [AppImportResponse](#appimportresponse)
| | 403 | Insufficient permissions | | ### [GET] /apps/{app_id}/export @@ -2789,10 +2790,10 @@ Convert text to speech for chat messages #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Text to speech conversion successful | **application/json**: [AudioBinaryResponse](#audiobinaryresponse)
| -| 400 | Bad request - Invalid parameters | | +| Code | Description | +| ---- | ----------- | +| 200 | Text to speech conversion successful | +| 400 | Bad request - Invalid parameters | ### [GET] /apps/{app_id}/text-to-audio/voices Get available TTS voices for a specific language @@ -3475,9 +3476,9 @@ Get default block configurations for workflow #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Default block configurations retrieved successfully | **application/json**: [DefaultBlockConfigsResponse](#defaultblockconfigsresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Default block configurations retrieved successfully | ### [GET] /apps/{app_id}/workflows/default-workflow-block-configs/{block_type} **Get default block config** @@ -3494,10 +3495,10 @@ Get default block configuration by type #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Default block configuration retrieved successfully | **application/json**: [DefaultBlockConfigResponse](#defaultblockconfigresponse)
| -| 404 | Block type not found | | +| Code | Description | +| ---- | ----------- | +| 200 | Default block configuration retrieved successfully | +| 404 | Block type not found | ### [GET] /apps/{app_id}/workflows/draft **Get draft workflow** @@ -3555,7 +3556,7 @@ Get conversation variables for workflow | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Conversation variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| +| 200 | Conversation variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| | 404 | Draft workflow not found | | ### [POST] /apps/{app_id}/workflows/draft/conversation-variables @@ -3594,7 +3595,7 @@ Get environment variables for workflow | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Environment variables retrieved successfully | **application/json**: [EnvironmentVariableListResponse](#environmentvariablelistresponse)
| +| 200 | Environment variables retrieved successfully | **application/json**: [WorkflowDraftEnvironmentVariableListResponse](#workflowdraftenvironmentvariablelistresponse)
| | 404 | Draft workflow not found | | ### [POST] /apps/{app_id}/workflows/draft/environment-variables @@ -3661,7 +3662,7 @@ Test human input delivery for workflow | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Human input delivery test result | **application/json**: [EmptyObjectResponse](#emptyobjectresponse)
| +| 200 | Human input delivery tested | **application/json**: [HumanInputDeliveryTestResponse](#humaninputdeliverytestresponse)
| ### [POST] /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/preview **Preview human input form content and placeholders** @@ -3685,7 +3686,7 @@ Get human input form preview for workflow | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Human input form preview | **application/json**: [HumanInputFormPreviewResponse](#humaninputformpreviewresponse)
| +| 200 | Human input form preview retrieved | **application/json**: [HumanInputFormPreviewResponse](#humaninputformpreviewresponse)
| ### [POST] /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/run **Submit human input form preview** @@ -3707,9 +3708,9 @@ Submit human input form preview for workflow #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Human input form submission result | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Human input form submitted | ### [POST] /apps/{app_id}/workflows/draft/iteration/nodes/{node_id}/run **Run draft workflow iteration node** @@ -3729,11 +3730,11 @@ Submit human input form preview for workflow #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow iteration node run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 403 | Permission denied | | -| 404 | Node not found | | +| Code | Description | +| ---- | ----------- | +| 200 | Workflow iteration node run started successfully | +| 403 | Permission denied | +| 404 | Node not found | ### [POST] /apps/{app_id}/workflows/draft/loop/nodes/{node_id}/run **Run draft workflow loop node** @@ -3753,11 +3754,11 @@ Submit human input form preview for workflow #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow loop node run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 403 | Permission denied | | -| 404 | Node not found | | +| Code | Description | +| ---- | ----------- | +| 200 | Workflow loop node run started successfully | +| 403 | Permission denied | +| 404 | Node not found | ### [GET] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer #### Parameters @@ -3943,7 +3944,7 @@ Get last run result for draft workflow node | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Trigger event received and node executed successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 200 | Trigger event received and node executed successfully | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| | 403 | Permission denied | | | 500 | Internal server error | | @@ -3977,7 +3978,7 @@ Get variables for a specific node | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| +| 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| ### [POST] /apps/{app_id}/workflows/draft/run **Run draft workflow** @@ -3996,10 +3997,10 @@ Get variables for a specific node #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Draft workflow run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 403 | Permission denied | | +| Code | Description | +| ---- | ----------- | +| 200 | Draft workflow run started successfully | +| 403 | Permission denied | ### [GET] /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs Snapshot of every node's declared outputs for a draft workflow run. @@ -4085,7 +4086,7 @@ Get system variables for workflow | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| +| 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| ### [POST] /apps/{app_id}/workflows/draft/trigger/run **Poll for trigger events and execute full workflow when event arrives** @@ -4100,15 +4101,15 @@ Get system variables for workflow | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [DraftWorkflowTriggerRunRequest](#draftworkflowtriggerrunrequest)
| +| Yes | **application/json**: [DraftWorkflowTriggerRunPayload](#draftworkflowtriggerrunpayload)
| #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Trigger event received and workflow executed successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 403 | Permission denied | | -| 500 | Internal server error | | +| Code | Description | +| ---- | ----------- | +| 200 | Trigger event received and workflow executed successfully | +| 403 | Permission denied | +| 500 | Internal server error | ### [POST] /apps/{app_id}/workflows/draft/trigger/run-all **Full workflow debug when the start node is a trigger** @@ -4127,11 +4128,11 @@ Get system variables for workflow #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow executed successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 403 | Permission denied | | -| 500 | Internal server error | | +| Code | Description | +| ---- | ----------- | +| 200 | Workflow executed successfully | +| 403 | Permission denied | +| 500 | Internal server error | ### [DELETE] /apps/{app_id}/workflows/draft/variables Delete all draft workflow variables @@ -4165,7 +4166,7 @@ Get draft workflow variables | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow variables retrieved successfully | **application/json**: [WorkflowDraftVariableListWithoutValue](#workflowdraftvariablelistwithoutvalue)
| +| 200 | Workflow variables retrieved successfully | **application/json**: [WorkflowDraftVariableListWithoutValueResponse](#workflowdraftvariablelistwithoutvalueresponse)
| ### [DELETE] /apps/{app_id}/workflows/draft/variables/{variable_id} Delete a workflow variable @@ -4198,7 +4199,7 @@ Get a specific workflow variable | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable retrieved successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| +| 200 | Variable retrieved successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| | 404 | Variable not found | | ### [PATCH] /apps/{app_id}/workflows/draft/variables/{variable_id} @@ -4221,7 +4222,7 @@ Update a workflow variable | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable updated successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| +| 200 | Variable updated successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| | 404 | Variable not found | | ### [PUT] /apps/{app_id}/workflows/draft/variables/{variable_id}/reset @@ -4238,7 +4239,7 @@ Reset a workflow variable to its default value | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable reset successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| +| 200 | Variable reset successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| | 204 | Variable reset (no content) | | | 404 | Variable not found | | @@ -4278,7 +4279,7 @@ Get published workflow for an application | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow published successfully | **application/json**: [WorkflowPublishResponse](#workflowpublishresponse)
| +| 200 | Workflow published successfully | **application/json**: [PublishWorkflowResponse](#publishworkflowresponse)
| ### [GET] /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs Snapshot of every node's declared outputs for a published workflow run. @@ -4423,7 +4424,7 @@ Restore a published workflow version into the draft workflow | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow restored successfully | **application/json**: [WorkflowRestoreResponse](#workflowrestoreresponse)
| +| 200 | Workflow restored successfully | **application/json**: [SyncDraftWorkflowResponse](#syncdraftworkflowresponse)
| | 400 | Source workflow must be published | | | 404 | Workflow not found | | @@ -4496,14 +4497,14 @@ Refresh MCP server configuration and regenerate server code | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)
| +| 200 | Default datasource credentials retrieved successfully | **application/json**: [DatasourceProviderAuthListResponse](#datasourceproviderauthlistresponse)
| ### [GET] /auth/plugin/datasource/list #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)
| +| 200 | Datasource credentials retrieved successfully | **application/json**: [DatasourceProviderAuthListResponse](#datasourceproviderauthlistresponse)
| ### [GET] /auth/plugin/datasource/{provider_id} #### Parameters @@ -4516,7 +4517,7 @@ Refresh MCP server configuration and regenerate server code | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)
| +| 200 | Datasource credentials retrieved successfully | **application/json**: [DatasourceCredentialListResponse](#datasourcecredentiallistresponse)
| ### [POST] /auth/plugin/datasource/{provider_id} #### Parameters @@ -4535,7 +4536,7 @@ Refresh MCP server configuration and regenerate server code | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 200 | Datasource credential created successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [DELETE] /auth/plugin/datasource/{provider_id}/custom-client #### Parameters @@ -4567,7 +4568,7 @@ Refresh MCP server configuration and regenerate server code | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 200 | Datasource OAuth custom client saved successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [POST] /auth/plugin/datasource/{provider_id}/default #### Parameters @@ -4624,7 +4625,7 @@ Refresh MCP server configuration and regenerate server code | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 201 | Datasource credential updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [POST] /auth/plugin/datasource/{provider_id}/update-name #### Parameters @@ -4882,7 +4883,7 @@ Create external knowledge dataset | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | External dataset created successfully | **application/json**: [DatasetDetail](#datasetdetail)
| +| 201 | External dataset created successfully | **application/json**: [DatasetDetailResponse](#datasetdetailresponse)
| | 400 | Invalid parameters | | | 403 | Permission denied | | @@ -4904,6 +4905,8 @@ Get external knowledge API templates | 200 | External API templates retrieved successfully | **application/json**: [ExternalKnowledgeApiListResponse](#externalknowledgeapilistresponse)
| ### [POST] /datasets/external-knowledge-api +Create external knowledge API template + #### Request Body | Required | Schema | @@ -4915,6 +4918,7 @@ Get external knowledge API templates | Code | Description | Schema | | ---- | ----------- | ------ | | 201 | External API template created successfully | **application/json**: [ExternalKnowledgeApiResponse](#externalknowledgeapiresponse)
| +| 403 | Permission denied | | ### [DELETE] /datasets/external-knowledge-api/{external_knowledge_api_id} #### Parameters @@ -4946,11 +4950,13 @@ Get external knowledge API template details | 404 | Template not found | | ### [PATCH] /datasets/external-knowledge-api/{external_knowledge_api_id} +Update external knowledge API template + #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| external_knowledge_api_id | path | | Yes | string (uuid) | +| external_knowledge_api_id | path | External knowledge API ID | Yes | string (uuid) | #### Request Body @@ -4963,6 +4969,7 @@ Get external knowledge API template details | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | External API template updated successfully | **application/json**: [ExternalKnowledgeApiResponse](#externalknowledgeapiresponse)
| +| 404 | Template not found | | ### [GET] /datasets/external-knowledge-api/{external_knowledge_api_id}/use-check Check if external knowledge API is being used @@ -5007,7 +5014,7 @@ Initialize dataset with documents | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Dataset initialized successfully | **application/json**: [DatasetAndDocumentResponse](#datasetanddocumentresponse)
| +| 200 | Dataset initialized successfully | **application/json**: [DatasetAndDocumentResponse](#datasetanddocumentresponse)
| | 400 | Invalid request parameters | | ### [GET] /datasets/metadata/built-in @@ -5043,7 +5050,7 @@ Get dataset document processing rules | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Process rules retrieved successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| +| 200 | Process rules retrieved successfully | **application/json**: [ProcessRuleResponse](#processruleresponse)
| ### [GET] /datasets/retrieval-setting Get dataset retrieval settings @@ -5164,7 +5171,7 @@ Get dataset auto disable logs | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Batch indexing estimate calculated successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| +| 200 | Indexing estimate calculated successfully | **application/json**: [IndexingEstimateResponse](#indexingestimateresponse)
| ### [GET] /datasets/{dataset_id}/batch/{batch}/indexing-status #### Parameters @@ -5252,9 +5259,9 @@ Download selected dataset documents as a single ZIP archive (upload-file only) #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | ZIP archive generated successfully | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | ZIP archive downloaded successfully | ### [POST] /datasets/{dataset_id}/documents/generate-summary **Generate summary index for specified documents** @@ -5347,7 +5354,7 @@ Get document details | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document retrieved successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| +| 200 | Document retrieved successfully | **application/json**: [DocumentDetailResponse](#documentdetailresponse)
| | 404 | Document not found | | ### [GET] /datasets/{dataset_id}/documents/{document_id}/download @@ -5380,7 +5387,7 @@ Estimate document indexing cost | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Indexing estimate calculated successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| +| 200 | Indexing estimate calculated successfully | **application/json**: [IndexingEstimateResponse](#indexingestimateresponse)
| | 400 | Document already finished | | | 404 | Document not found | | @@ -5451,7 +5458,7 @@ Update document metadata | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document pipeline execution log retrieved successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| +| 200 | Pipeline execution log retrieved successfully | **application/json**: [DocumentPipelineExecutionLogResponse](#documentpipelineexecutionlogresponse)
| ### [PATCH] /datasets/{dataset_id}/documents/{document_id}/processing/pause **pause document** @@ -5774,6 +5781,7 @@ Returns: - generating: Number of summaries being generated - error: Number of summaries with errors - not_started: Number of segments without summary records + - timeout: Number of summaries that timed out - summaries: List of summary records with status and content preview #### Parameters @@ -5787,7 +5795,7 @@ Returns: | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Summary status retrieved successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| +| 200 | Summary status retrieved successfully | **application/json**: [DocumentSummaryStatusResponse](#documentsummarystatusresponse)
| | 404 | Document not found | | ### [GET] /datasets/{dataset_id}/documents/{document_id}/website-sync @@ -5841,7 +5849,7 @@ Test external knowledge retrieval for dataset | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | External hit testing completed successfully | **application/json**: [ExternalRetrievalTestResponse](#externalretrievaltestresponse)
| +| 200 | External hit testing completed successfully | **application/json**: [ExternalHitTestingResponse](#externalhittestingresponse)
| | 400 | Invalid parameters | | | 404 | Dataset not found | | @@ -6451,9 +6459,9 @@ Request body: #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [POST] /installed-apps/{installed_app_id}/chat-messages/{task_id}/stop #### Parameters @@ -6484,9 +6492,9 @@ Request body: #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [POST] /installed-apps/{installed_app_id}/completion-messages/{task_id}/stop #### Parameters @@ -6627,9 +6635,9 @@ Request body: #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [GET] /installed-apps/{installed_app_id}/messages/{message_id}/suggested-questions #### Parameters @@ -6759,9 +6767,9 @@ Request body: #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [POST] /installed-apps/{installed_app_id}/workflows/tasks/{task_id}/stop **Stop workflow task** @@ -6844,9 +6852,9 @@ Get instruction generation template #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 302 | Redirect to console OAuth callback page | **application/json**: [RedirectResponse](#redirectresponse)
| +| Code | Description | +| ---- | ----------- | +| 302 | Redirect to OAuth callback page | ### [GET] /notification Return the active in-product notification for the current user in their interface language (falls back to English if unavailable). The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal. @@ -7022,9 +7030,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 302 | Redirect to console OAuth callback page | **application/json**: [RedirectResponse](#redirectresponse)
| +| Code | Description | +| ---- | ----------- | +| 302 | Redirect to OAuth callback page | ### [GET] /oauth/plugin/{provider_id}/datasource/get-authorization-url #### Parameters @@ -7038,7 +7046,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Authorization URL retrieved successfully | **application/json**: [PluginOAuthAuthorizationUrlResponse](#pluginoauthauthorizationurlresponse)
| +| 200 | Datasource OAuth authorization URL generated successfully | **application/json**: [PluginOAuthAuthorizationUrlResponse](#pluginoauthauthorizationurlresponse)
| ### [GET] /oauth/plugin/{provider}/tool/authorization-url #### Parameters @@ -7051,7 +7059,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Authorization URL retrieved successfully | **application/json**: [PluginOAuthAuthorizationUrlResponse](#pluginoauthauthorizationurlresponse)
| +| 200 | Tool OAuth authorization URL generated successfully | **application/json**: [PluginOAuthAuthorizationUrlResponse](#pluginoauthauthorizationurlresponse)
| ### [GET] /oauth/plugin/{provider}/tool/callback #### Parameters @@ -7062,9 +7070,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 302 | Redirect to console OAuth callback page | **application/json**: [RedirectResponse](#redirectresponse)
| +| Code | Description | +| ---- | ----------- | +| 302 | Redirect to OAuth callback page | ### [GET] /oauth/plugin/{provider}/trigger/callback **Handle OAuth callback for trigger provider** @@ -7077,9 +7085,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 302 | Redirect to console OAuth callback page | **application/json**: [RedirectResponse](#redirectresponse)
| +| Code | Description | +| ---- | ----------- | +| 302 | Redirect to OAuth callback page | ### [POST] /oauth/provider #### Request Body @@ -7231,7 +7239,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| 200 | Datasource plugins retrieved successfully | **application/json**: [DatasourcePluginListResponse](#datasourcepluginlistresponse)
| ### [POST] /rag/pipelines/imports #### Request Body @@ -7286,7 +7294,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| 200 | Recommended plugins retrieved successfully | **application/json**: [RagPipelineRecommendedPluginResponse](#ragpipelinerecommendedpluginresponse)
| ### [POST] /rag/pipelines/transform/datasets/{dataset_id} #### Parameters @@ -7299,7 +7307,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| 200 | Dataset transformed successfully | **application/json**: [RagPipelineTransformResponse](#ragpipelinetransformresponse)
| ### [POST] /rag/pipelines/{pipeline_id}/customized/publish #### Parameters @@ -7430,9 +7438,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Default block configs retrieved successfully | **application/json**: [DefaultBlockConfigsResponse](#defaultblockconfigsresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Default workflow block configurations retrieved successfully | ### [GET] /rag/pipelines/{pipeline_id}/workflows/default-workflow-block-configs/{block_type} **Get default block config** @@ -7447,9 +7455,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Default block config retrieved successfully | **application/json**: [DefaultBlockConfigResponse](#defaultblockconfigresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Default workflow block configuration retrieved successfully | ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft **Get draft rag pipeline's workflow** @@ -7506,9 +7514,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/datasource/variables-inspect **Set datasource variables** @@ -7544,7 +7552,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Environment variables retrieved successfully | **application/json**: [EnvironmentVariableListResponse](#environmentvariablelistresponse)
| +| 200 | Environment variables retrieved successfully | **application/json**: [RagPipelineEnvironmentVariableListResponse](#ragpipelineenvironmentvariablelistresponse)
| ### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/iteration/nodes/{node_id}/run **Run draft workflow iteration node** @@ -7564,9 +7572,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/loop/nodes/{node_id}/run **Run draft workflow loop node** @@ -7586,9 +7594,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/last-run #### Parameters @@ -7652,7 +7660,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| +| 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/pre-processing/parameters **Get first step parameters of rag pipeline** @@ -7668,7 +7676,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineStepParametersResponse](#ragpipelinestepparametersresponse)
| +| 200 | First step parameters retrieved successfully | **application/json**: [RagPipelineVariablesResponse](#ragpipelinevariablesresponse)
| ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/processing/parameters **Get second step parameters of rag pipeline** @@ -7684,7 +7692,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineStepParametersResponse](#ragpipelinestepparametersresponse)
| +| 200 | Second step parameters retrieved successfully | **application/json**: [RagPipelineVariablesResponse](#ragpipelinevariablesresponse)
| ### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/run **Run draft workflow** @@ -7703,9 +7711,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/system-variables #### Parameters @@ -7718,7 +7726,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| +| 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| ### [DELETE] /rag/pipelines/{pipeline_id}/workflows/draft/variables #### Parameters @@ -7731,7 +7739,7 @@ Initiate OAuth login process | Code | Description | | ---- | ----------- | -| 204 | Workflow variables deleted successfully | +| 204 | Variables deleted successfully | ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/variables **Get draft workflow** @@ -7740,15 +7748,13 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| limit | query | | No | integer,
**Default:** 20 | -| page | query | | No | integer,
**Default:** 1 | | pipeline_id | path | | Yes | string (uuid) | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow variables retrieved successfully | **application/json**: [WorkflowDraftVariableListWithoutValue](#workflowdraftvariablelistwithoutvalue)
| +| 200 | Variables retrieved successfully | **application/json**: [WorkflowDraftVariableListWithoutValueResponse](#workflowdraftvariablelistwithoutvalueresponse)
| ### [DELETE] /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id} #### Parameters @@ -7776,7 +7782,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable retrieved successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| +| 200 | Variable retrieved successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| ### [PATCH] /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id} #### Parameters @@ -7796,7 +7802,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable updated successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| +| 200 | Variable updated successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| ### [PUT] /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}/reset #### Parameters @@ -7810,8 +7816,8 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable reset successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| -| 204 | Variable reset (no content) | | +| 200 | Variable reset successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| +| 204 | Variable reset to empty state | | ### [GET] /rag/pipelines/{pipeline_id}/workflows/publish **Get published pipeline** @@ -7861,9 +7867,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [DataSourceContentPreviewResponse](#datasourcecontentpreviewresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [POST] /rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/run **Run rag pipeline datasource** @@ -7883,9 +7889,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [GET] /rag/pipelines/{pipeline_id}/workflows/published/pre-processing/parameters **Get first step parameters of rag pipeline** @@ -7901,7 +7907,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineStepParametersResponse](#ragpipelinestepparametersresponse)
| +| 200 | First step parameters retrieved successfully | **application/json**: [RagPipelineVariablesResponse](#ragpipelinevariablesresponse)
| ### [GET] /rag/pipelines/{pipeline_id}/workflows/published/processing/parameters **Get second step parameters of rag pipeline** @@ -7917,7 +7923,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineStepParametersResponse](#ragpipelinestepparametersresponse)
| +| 200 | Second step parameters retrieved successfully | **application/json**: [RagPipelineVariablesResponse](#ragpipelinevariablesresponse)
| ### [POST] /rag/pipelines/{pipeline_id}/workflows/published/run **Run published workflow** @@ -7936,9 +7942,9 @@ Initiate OAuth login process #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [DELETE] /rag/pipelines/{pipeline_id}/workflows/{workflow_id} **Delete a published workflow version that is not currently active on the pipeline** @@ -8001,6 +8007,7 @@ Initiate OAuth login process | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 401 | Unauthorized | **application/json**: [SimpleResultMessageResponse](#simpleresultmessageresponse)
| ### [POST] /remote-files/upload #### Request Body @@ -8192,9 +8199,9 @@ Get all published workflows for a snippet #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Default block configs retrieved successfully | **application/json**: [DefaultBlockConfigsResponse](#defaultblockconfigsresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Default block configs retrieved successfully | ### [GET] /snippets/{snippet_id}/workflows/draft **Get draft workflow for snippet** @@ -8231,7 +8238,7 @@ Get all published workflows for a snippet | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Draft workflow synced successfully | **application/json**: [WorkflowRestoreResponse](#workflowrestoreresponse)
| +| 200 | Draft workflow synced successfully | **application/json**: [SyncDraftWorkflowResponse](#syncdraftworkflowresponse)
| | 400 | Hash mismatch | | ### [GET] /snippets/{snippet_id}/workflows/draft/config @@ -8262,7 +8269,7 @@ Conversation variables are not used in snippet workflows; returns an empty list | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Conversation variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| +| 200 | Conversation variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| ### [GET] /snippets/{snippet_id}/workflows/draft/environment-variables Get environment variables from snippet draft workflow graph @@ -8277,7 +8284,7 @@ Get environment variables from snippet draft workflow graph | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Environment variables retrieved successfully | **application/json**: [EnvironmentVariableListResponse](#environmentvariablelistresponse)
| +| 200 | Environment variables retrieved successfully | **application/json**: [WorkflowDraftEnvironmentVariableListResponse](#workflowdraftenvironmentvariablelistresponse)
| | 404 | Draft workflow not found | | ### [POST] /snippets/{snippet_id}/workflows/draft/iteration/nodes/{node_id}/run @@ -8304,7 +8311,7 @@ Returns an SSE event stream with iteration progress and results. | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Iteration node run started successfully (SSE stream) | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 200 | Iteration node run started successfully (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)
| | 404 | Snippet or draft workflow not found | | ### [POST] /snippets/{snippet_id}/workflows/draft/loop/nodes/{node_id}/run @@ -8331,7 +8338,7 @@ Returns an SSE event stream with loop progress and results. | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Loop node run started successfully (SSE stream) | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 200 | Loop node run started successfully (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)
| | 404 | Snippet or draft workflow not found | | ### [GET] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/last-run @@ -8412,7 +8419,7 @@ Get variables for a specific node (snippet draft workflow) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| +| 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| ### [POST] /snippets/{snippet_id}/workflows/draft/run **Run draft workflow for snippet** @@ -8436,7 +8443,7 @@ and returns an SSE event stream with execution progress and results. | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Draft workflow run started successfully (SSE stream) | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 200 | Draft workflow run started successfully (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)
| | 404 | Snippet or draft workflow not found | | ### [GET] /snippets/{snippet_id}/workflows/draft/system-variables @@ -8452,7 +8459,7 @@ System variables are not used in snippet workflows; returns an empty list for AP | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| +| 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| ### [DELETE] /snippets/{snippet_id}/workflows/draft/variables Delete all draft workflow variables for the current user (snippet scope) @@ -8484,7 +8491,7 @@ List draft workflow variables without values (paginated, snippet scope) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow variables retrieved successfully | **application/json**: [WorkflowDraftVariableListWithoutValue](#workflowdraftvariablelistwithoutvalue)
| +| 200 | Workflow variables retrieved successfully | **application/json**: [WorkflowDraftVariableListWithoutValueResponse](#workflowdraftvariablelistwithoutvalueresponse)
| ### [DELETE] /snippets/{snippet_id}/workflows/draft/variables/{variable_id} Delete a draft workflow variable (snippet scope) @@ -8517,7 +8524,7 @@ Get a specific draft workflow variable (snippet scope) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable retrieved successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| +| 200 | Variable retrieved successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| | 404 | Variable not found | | ### [PATCH] /snippets/{snippet_id}/workflows/draft/variables/{variable_id} @@ -8540,7 +8547,7 @@ Update a draft workflow variable (snippet scope) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable updated successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| +| 200 | Variable updated successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| | 404 | Variable not found | | ### [PUT] /snippets/{snippet_id}/workflows/draft/variables/{variable_id}/reset @@ -8557,7 +8564,7 @@ Reset a draft workflow variable to its default value (snippet scope) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable reset successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| +| 200 | Variable reset successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| | 204 | Variable reset (no content) | | | 404 | Variable not found | | @@ -8586,17 +8593,11 @@ Reset a draft workflow variable to its default value (snippet scope) | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string (uuid) | -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [PublishWorkflowPayload](#publishworkflowpayload)
| - #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow published successfully | **application/json**: [WorkflowPublishResponse](#workflowpublishresponse)
| +| 200 | Workflow published successfully | **application/json**: [PublishWorkflowResponse](#publishworkflowresponse)
| | 400 | No draft workflow found | | ### [POST] /snippets/{snippet_id}/workflows/{workflow_id}/restore @@ -8613,7 +8614,7 @@ Reset a draft workflow variable to its default value (snippet scope) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow restored successfully | **application/json**: [WorkflowRestoreResponse](#workflowrestoreresponse)
| +| 200 | Workflow restored successfully | **application/json**: [SyncDraftWorkflowResponse](#syncdraftworkflowresponse)
| | 400 | Source workflow must be published | | | 404 | Workflow not found | | @@ -8685,7 +8686,7 @@ Remove one or more tag bindings from a target. | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ [TagResponse](#tagresponse) ]
| +| 200 | Success | **application/json**: [TagListResponse](#taglistresponse)
| ### [POST] /tags #### Request Body @@ -8745,7 +8746,7 @@ Bedrock retrieval test (internal use only) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Bedrock retrieval test completed | **application/json**: [ExternalRetrievalTestResponse](#externalretrievaltestresponse)
| +| 200 | Bedrock retrieval test completed | **application/json**: [BedrockRetrievalResponse](#bedrockretrievalresponse)
| ### [GET] /trial-apps/{app_id} **Get app detail** @@ -8760,7 +8761,7 @@ Bedrock retrieval test (internal use only) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TrialAppDetailWithSite](#trialappdetailwithsite)
| +| 200 | App detail retrieved successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| ### [POST] /trial-apps/{app_id}/audio-to-text #### Parameters @@ -8790,9 +8791,9 @@ Bedrock retrieval test (internal use only) #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [POST] /trial-apps/{app_id}/completion-messages #### Parameters @@ -8809,9 +8810,9 @@ Bedrock retrieval test (internal use only) #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [GET] /trial-apps/{app_id}/datasets #### Parameters @@ -8827,7 +8828,7 @@ Bedrock retrieval test (internal use only) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TrialDatasetList](#trialdatasetlist)
| +| 200 | Success | **application/json**: [TrialDatasetListResponse](#trialdatasetlistresponse)
| ### [GET] /trial-apps/{app_id}/messages/{message_id}/suggested-questions #### Parameters @@ -8907,7 +8908,7 @@ Returns the site configuration for the application including theme, icons, and t | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TrialWorkflow](#trialworkflow)
| +| 200 | Workflow detail retrieved successfully | **application/json**: [WorkflowResponse](#workflowresponse)
| ### [POST] /trial-apps/{app_id}/workflows/run **Run workflow** @@ -8926,9 +8927,9 @@ Returns the site configuration for the application including theme, icons, and t #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Success | ### [POST] /trial-apps/{app_id}/workflows/tasks/{task_id}/stop **Stop workflow task** @@ -9104,7 +9105,7 @@ Get list of available agent providers | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Snippets retrieved successfully | **application/json**: [SnippetPagination](#snippetpagination)
| +| 200 | Snippets retrieved successfully | **application/json**: [SnippetPaginationResponse](#snippetpaginationresponse)
| ### [POST] /workspaces/current/customized-snippets **Create a new customized snippet** @@ -9119,7 +9120,7 @@ Get list of available agent providers | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Snippet created successfully | **application/json**: [Snippet](#snippet)
| +| 201 | Snippet created successfully | **application/json**: [SnippetResponse](#snippetresponse)
| | 400 | Invalid request | | ### [POST] /workspaces/current/customized-snippets/imports @@ -9135,8 +9136,8 @@ Get list of available agent providers | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Snippet imported successfully | **application/json**: [SnippetImportResponse](#snippetimportresponse)
| -| 202 | Import pending confirmation | **application/json**: [SnippetImportResponse](#snippetimportresponse)
| +| 200 | Snippet imported successfully | **application/json**: [SnippetImportInfo](#snippetimportinfo)
| +| 202 | Import pending confirmation | **application/json**: [SnippetImportInfo](#snippetimportinfo)
| | 400 | Import failed | | ### [POST] /workspaces/current/customized-snippets/imports/{import_id}/confirm @@ -9152,7 +9153,7 @@ Get list of available agent providers | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Import confirmed successfully | **application/json**: [SnippetImportResponse](#snippetimportresponse)
| +| 200 | Import confirmed successfully | **application/json**: [SnippetImportInfo](#snippetimportinfo)
| | 400 | Import failed | | ### [DELETE] /workspaces/current/customized-snippets/{snippet_id} @@ -9184,7 +9185,7 @@ Get list of available agent providers | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Snippet retrieved successfully | **application/json**: [Snippet](#snippet)
| +| 200 | Snippet retrieved successfully | **application/json**: [SnippetResponse](#snippetresponse)
| | 404 | Snippet not found | | ### [PATCH] /workspaces/current/customized-snippets/{snippet_id} @@ -9206,7 +9207,7 @@ Get list of available agent providers | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Snippet updated successfully | **application/json**: [Snippet](#snippet)
| +| 200 | Snippet updated successfully | **application/json**: [SnippetResponse](#snippetresponse)
| | 400 | Invalid request | | | 404 | Snippet not found | | @@ -9223,7 +9224,7 @@ Get list of available agent providers | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Dependencies checked successfully | **application/json**: [SnippetDependencyCheckResponse](#snippetdependencycheckresponse)
| +| 200 | Dependencies checked successfully | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)
| | 404 | Snippet not found | | ### [GET] /workspaces/current/customized-snippets/{snippet_id}/export @@ -9240,10 +9241,10 @@ Export snippet configuration as DSL #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Snippet exported successfully | **application/json**: [TextFileResponse](#textfileresponse)
| -| 404 | Snippet not found | | +| Code | Description | +| ---- | ----------- | +| 200 | Snippet exported successfully | +| 404 | Snippet not found | ### [POST] /workspaces/current/customized-snippets/{snippet_id}/use-count/increment **Increment snippet use count when it is inserted into a workflow** @@ -9260,7 +9261,7 @@ Increment snippet use count by 1 | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Use count incremented successfully | **application/json**: [SnippetUseCountResponse](#snippetusecountresponse)
| +| 200 | Use count incremented successfully | **application/json**: [SnippetUseCountIncrementResponse](#snippetusecountincrementresponse)
| | 404 | Snippet not found | | ### [GET] /workspaces/current/dataset-operators @@ -9268,7 +9269,7 @@ Increment snippet use count by 1 | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [AccountWithRoleList](#accountwithrolelist)
| +| 200 | Success | **application/json**: [AccountWithRoleListResponse](#accountwithrolelistresponse)
| ### [GET] /workspaces/current/default-model #### Parameters @@ -9281,7 +9282,7 @@ Increment snippet use count by 1 | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [DefaultModelDataResponse](#defaultmodeldataresponse)
| +| 200 | Default model retrieved successfully | **application/json**: [DefaultModelDataResponse](#defaultmodeldataresponse)
| ### [POST] /workspaces/current/default-model #### Request Body @@ -9309,7 +9310,7 @@ Create a new plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint created successfully | **application/json**: [EndpointCreateResponse](#endpointcreateresponse)
| +| 200 | Endpoint created successfully | **application/json**: [SuccessResponse](#successresponse)
| | 403 | Admin privileges required | | ### ~~[POST] /workspaces/current/endpoints/create~~ @@ -9328,7 +9329,7 @@ Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/cur | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint created successfully | **application/json**: [EndpointCreateResponse](#endpointcreateresponse)
| +| 200 | Endpoint created successfully | **application/json**: [SuccessResponse](#successresponse)
| | 403 | Admin privileges required | | ### ~~[POST] /workspaces/current/endpoints/delete~~ @@ -9347,7 +9348,7 @@ Deprecated legacy alias for deleting a plugin endpoint. Use DELETE /workspaces/c | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint deleted successfully | **application/json**: [EndpointDeleteResponse](#endpointdeleteresponse)
| +| 200 | Endpoint deleted successfully | **application/json**: [SuccessResponse](#successresponse)
| | 403 | Admin privileges required | | ### [POST] /workspaces/current/endpoints/disable @@ -9363,7 +9364,7 @@ Disable a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint disabled successfully | **application/json**: [EndpointDisableResponse](#endpointdisableresponse)
| +| 200 | Endpoint disabled successfully | **application/json**: [SuccessResponse](#successresponse)
| | 403 | Admin privileges required | | ### [POST] /workspaces/current/endpoints/enable @@ -9379,7 +9380,7 @@ Enable a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint enabled successfully | **application/json**: [EndpointEnableResponse](#endpointenableresponse)
| +| 200 | Endpoint enabled successfully | **application/json**: [SuccessResponse](#successresponse)
| | 403 | Admin privileges required | | ### [GET] /workspaces/current/endpoints/list @@ -9413,7 +9414,7 @@ List endpoints for a specific plugin | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginEndpointListResponse](#pluginendpointlistresponse)
| +| 200 | Success | **application/json**: [EndpointListResponse](#endpointlistresponse)
| ### ~~[POST] /workspaces/current/endpoints/update~~ @@ -9431,7 +9432,7 @@ Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/cu | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint updated successfully | **application/json**: [EndpointUpdateResponse](#endpointupdateresponse)
| +| 200 | Endpoint updated successfully | **application/json**: [SuccessResponse](#successresponse)
| | 403 | Admin privileges required | | ### [DELETE] /workspaces/current/endpoints/{id} @@ -9447,7 +9448,7 @@ Delete a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint deleted successfully | **application/json**: [EndpointDeleteResponse](#endpointdeleteresponse)
| +| 200 | Endpoint deleted successfully | **application/json**: [SuccessResponse](#successresponse)
| | 403 | Admin privileges required | | ### [PATCH] /workspaces/current/endpoints/{id} @@ -9469,7 +9470,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint updated successfully | **application/json**: [EndpointUpdateResponse](#endpointupdateresponse)
| +| 200 | Endpoint updated successfully | **application/json**: [SuccessResponse](#successresponse)
| | 403 | Admin privileges required | | ### [GET] /workspaces/current/members @@ -9477,7 +9478,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [AccountWithRoleList](#accountwithrolelist)
| +| 200 | Success | **application/json**: [AccountWithRoleListResponse](#accountwithrolelistresponse)
| ### [POST] /workspaces/current/members/invite-email #### Request Body @@ -9529,7 +9530,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [MemberActionTenantResponse](#memberactiontenantresponse)
| +| 200 | Success | **application/json**: [MemberActionResponse](#memberactionresponse)
| ### [POST] /workspaces/current/members/{member_id}/owner-transfer #### Parameters @@ -9580,7 +9581,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ModelProviderListResponse](#modelproviderlistresponse)
| +| 200 | Model providers retrieved successfully | **application/json**: [ModelProviderListResponse](#modelproviderlistresponse)
| ### [GET] /workspaces/current/model-providers/{provider}/checkout-url #### Parameters @@ -9593,7 +9594,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ModelProviderPaymentCheckoutUrlResponse](#modelproviderpaymentcheckouturlresponse)
| +| 200 | Model provider checkout URL retrieved successfully | **application/json**: [ModelProviderPaymentCheckoutUrlResponse](#modelproviderpaymentcheckouturlresponse)
| ### [DELETE] /workspaces/current/model-providers/{provider}/credentials #### Parameters @@ -9626,7 +9627,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ProviderCredentialResponse](#providercredentialresponse)
| +| 200 | Provider credentials retrieved successfully | **application/json**: [ProviderCredentialsResponse](#providercredentialsresponse)
| ### [POST] /workspaces/current/model-providers/{provider}/credentials #### Parameters @@ -9702,7 +9703,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Credential validation result | **application/json**: [ProviderCredentialValidateResponse](#providercredentialvalidateresponse)
| +| 200 | Provider credentials validated successfully | **application/json**: [ValidationResultResponse](#validationresultresponse)
| ### [DELETE] /workspaces/current/model-providers/{provider}/models #### Parameters @@ -9734,7 +9735,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ModelWithProviderListResponse](#modelwithproviderlistresponse)
| +| 200 | Provider models retrieved successfully | **application/json**: [ProviderModelListResponse](#providermodellistresponse)
| ### [POST] /workspaces/current/model-providers/{provider}/models #### Parameters @@ -9753,7 +9754,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 200 | Model updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [DELETE] /workspaces/current/model-providers/{provider}/models/credentials #### Parameters @@ -9789,7 +9790,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ModelCredentialResponse](#modelcredentialresponse)
| +| 200 | Model credentials retrieved successfully | **application/json**: [ModelCredentialResponse](#modelcredentialresponse)
| ### [POST] /workspaces/current/model-providers/{provider}/models/credentials #### Parameters @@ -9808,7 +9809,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Credential created successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 201 | Model credential created successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [PUT] /workspaces/current/model-providers/{provider}/models/credentials #### Parameters @@ -9827,7 +9828,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Credential updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 200 | Model credential updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [POST] /workspaces/current/model-providers/{provider}/models/credentials/switch #### Parameters @@ -9865,7 +9866,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Credential validation result | **application/json**: [ModelCredentialValidateResponse](#modelcredentialvalidateresponse)
| +| 200 | Model credentials validated successfully | **application/json**: [ValidationResultResponse](#validationresultresponse)
| ### [PATCH] /workspaces/current/model-providers/{provider}/models/disable #### Parameters @@ -9956,7 +9957,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ModelParameterRulesResponse](#modelparameterrulesresponse)
| +| 200 | Model parameter rules retrieved successfully | **application/json**: [ModelParameterRuleListResponse](#modelparameterrulelistresponse)
| ### [POST] /workspaces/current/model-providers/{provider}/preferred-provider-type #### Parameters @@ -9988,7 +9989,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ProviderWithModelsDataResponse](#providerwithmodelsdataresponse)
| +| 200 | Available models retrieved successfully | **application/json**: [AvailableModelListResponse](#availablemodellistresponse)
| ### [GET] /workspaces/current/permission **Get workspace permission settings** @@ -10099,7 +10100,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| +| 200 | Success | **application/json**: [PluginInstallTaskStartResponse](#plugininstalltaskstartresponse)
| ### [POST] /workspaces/current/plugin/install/marketplace #### Request Body @@ -10112,7 +10113,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| +| 200 | Success | **application/json**: [PluginInstallTaskStartResponse](#plugininstalltaskstartresponse)
| ### [POST] /workspaces/current/plugin/install/pkg #### Request Body @@ -10125,7 +10126,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| +| 200 | Success | **application/json**: [PluginInstallTaskStartResponse](#plugininstalltaskstartresponse)
| ### [GET] /workspaces/current/plugin/list #### Parameters @@ -10332,7 +10333,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| +| 200 | Success | **application/json**: [PluginInstallTaskStartResponse](#plugininstalltaskstartresponse)
| ### [POST] /workspaces/current/plugin/upgrade/marketplace #### Request Body @@ -10345,14 +10346,14 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| +| 200 | Success | **application/json**: [PluginInstallTaskStartResponse](#plugininstalltaskstartresponse)
| ### [POST] /workspaces/current/plugin/upload/bundle #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| +| 200 | Success | **application/json**: [PluginBundleUploadResponse](#pluginbundleuploadresponse)
| ### [POST] /workspaces/current/plugin/upload/github #### Request Body @@ -10365,14 +10366,14 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| +| 200 | Success | **application/json**: [PluginDecodeResponse](#plugindecoderesponse)
| ### [POST] /workspaces/current/plugin/upload/pkg #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| +| 200 | Success | **application/json**: [PluginDecodeResponse](#plugindecoderesponse)
| ### [GET] /workspaces/current/plugin/{category}/list #### Parameters @@ -10927,7 +10928,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Tool labels retrieved successfully | **application/json**: [ToolLabelListResponse](#toollabellistresponse)
| ### [POST] /workspaces/current/tool-provider/api/add #### Request Body @@ -10940,7 +10941,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | API provider added successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [POST] /workspaces/current/tool-provider/api/delete #### Request Body @@ -10953,7 +10954,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | API provider deleted successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [GET] /workspaces/current/tool-provider/api/get #### Parameters @@ -10966,7 +10967,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | API provider retrieved successfully | **application/json**: [ApiProviderDetailResponse](#apiproviderdetailresponse)
| ### [GET] /workspaces/current/tool-provider/api/remote #### Parameters @@ -10979,7 +10980,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Remote API provider schema retrieved successfully | **application/json**: [ApiProviderRemoteSchemaResponse](#apiproviderremoteschemaresponse)
| ### [POST] /workspaces/current/tool-provider/api/schema #### Request Body @@ -10992,7 +10993,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | API schema parsed successfully | **application/json**: [ApiSchemaParseResponse](#apischemaparseresponse)
| ### [POST] /workspaces/current/tool-provider/api/test/pre #### Request Body @@ -11005,7 +11006,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | API tool test preview completed successfully | **application/json**: [ApiToolPreviewResponse](#apitoolpreviewresponse)
| ### [GET] /workspaces/current/tool-provider/api/tools #### Parameters @@ -11018,7 +11019,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | API provider tools retrieved successfully | **application/json**: [ToolApiListResponse](#toolapilistresponse)
| ### [POST] /workspaces/current/tool-provider/api/update #### Request Body @@ -11031,7 +11032,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | API provider updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [POST] /workspaces/current/tool-provider/builtin/{provider}/add #### Parameters @@ -11050,7 +11051,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Builtin provider added successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [GET] /workspaces/current/tool-provider/builtin/{provider}/credential/info #### Parameters @@ -11064,7 +11065,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Builtin provider credential info retrieved successfully | **application/json**: [ToolProviderCredentialInfoApiEntity](#toolprovidercredentialinfoapientity)
| ### [GET] /workspaces/current/tool-provider/builtin/{provider}/credential/schema/{credential_type} #### Parameters @@ -11078,7 +11079,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Builtin provider credential schema retrieved successfully | **application/json**: [ProviderConfigListResponse](#providerconfiglistresponse)
| ### [GET] /workspaces/current/tool-provider/builtin/{provider}/credentials #### Parameters @@ -11092,7 +11093,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Builtin provider credentials retrieved successfully | **application/json**: [ToolProviderCredentialListResponse](#toolprovidercredentiallistresponse)
| ### [POST] /workspaces/current/tool-provider/builtin/{provider}/default-credential #### Parameters @@ -11111,7 +11112,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Default credential set successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [POST] /workspaces/current/tool-provider/builtin/{provider}/delete #### Parameters @@ -11130,7 +11131,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Builtin provider credential deleted successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [GET] /workspaces/current/tool-provider/builtin/{provider}/icon #### Parameters @@ -11141,9 +11142,9 @@ Returns permission flags that control workspace features like member invitations #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Builtin provider icon | ### [GET] /workspaces/current/tool-provider/builtin/{provider}/info #### Parameters @@ -11156,7 +11157,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Builtin provider info retrieved successfully | **application/json**: [ToolProviderApiEntityResponse](#toolproviderapientityresponse)
| ### [GET] /workspaces/current/tool-provider/builtin/{provider}/oauth/client-schema #### Parameters @@ -11169,7 +11170,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolOAuthClientSchemaResponse](#tooloauthclientschemaresponse)
| +| 200 | Builtin provider OAuth client schema retrieved successfully | **application/json**: [BuiltinProviderOAuthClientSchemaResponse](#builtinprovideroauthclientschemaresponse)
| ### [DELETE] /workspaces/current/tool-provider/builtin/{provider}/oauth/custom-client #### Parameters @@ -11182,7 +11183,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 200 | Custom OAuth client deleted successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [GET] /workspaces/current/tool-provider/builtin/{provider}/oauth/custom-client #### Parameters @@ -11193,9 +11194,9 @@ Returns permission flags that control workspace features like member invitations #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolOAuthCustomClientResponse](#tooloauthcustomclientresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Custom OAuth client retrieved successfully | ### [POST] /workspaces/current/tool-provider/builtin/{provider}/oauth/custom-client #### Parameters @@ -11214,7 +11215,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 200 | Custom OAuth client saved successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [GET] /workspaces/current/tool-provider/builtin/{provider}/tools #### Parameters @@ -11227,7 +11228,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Builtin provider tools retrieved successfully | **application/json**: [ToolApiListResponse](#toolapilistresponse)
| ### [POST] /workspaces/current/tool-provider/builtin/{provider}/update #### Parameters @@ -11246,7 +11247,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Builtin provider updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [DELETE] /workspaces/current/tool-provider/mcp #### Request Body @@ -11272,7 +11273,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | MCP provider created successfully | **application/json**: [ToolProviderApiEntityResponse](#toolproviderapientityresponse)
| ### [PUT] /workspaces/current/tool-provider/mcp #### Request Body @@ -11285,7 +11286,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 200 | MCP provider updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [POST] /workspaces/current/tool-provider/mcp/auth #### Request Body @@ -11298,7 +11299,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | MCP provider authorized successfully | **application/json**: [MCPAuthResponse](#mcpauthresponse)
| ### [GET] /workspaces/current/tool-provider/mcp/tools/{provider_id} #### Parameters @@ -11311,7 +11312,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | MCP provider retrieved successfully | **application/json**: [ToolProviderApiEntityResponse](#toolproviderapientityresponse)
| ### [GET] /workspaces/current/tool-provider/mcp/update/{provider_id} #### Parameters @@ -11324,7 +11325,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | MCP provider tools refreshed successfully | **application/json**: [ToolProviderApiEntityResponse](#toolproviderapientityresponse)
| ### [POST] /workspaces/current/tool-provider/workflow/create #### Request Body @@ -11337,7 +11338,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Workflow tool created successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [POST] /workspaces/current/tool-provider/workflow/delete #### Request Body @@ -11350,7 +11351,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Workflow tool deleted successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [GET] /workspaces/current/tool-provider/workflow/get #### Parameters @@ -11364,7 +11365,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Workflow tool retrieved successfully | **application/json**: [WorkflowToolDetailResponse](#workflowtooldetailresponse)
| ### [GET] /workspaces/current/tool-provider/workflow/tools #### Parameters @@ -11377,7 +11378,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Workflow provider tools retrieved successfully | **application/json**: [ToolApiListResponse](#toolapilistresponse)
| ### [POST] /workspaces/current/tool-provider/workflow/update #### Request Body @@ -11390,7 +11391,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Workflow tool updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [GET] /workspaces/current/tool-providers #### Parameters @@ -11403,35 +11404,35 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Tool providers retrieved successfully | **application/json**: [ToolProviderListResponse](#toolproviderlistresponse)
| ### [GET] /workspaces/current/tools/api #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | API tools retrieved successfully | **application/json**: [ToolProviderListResponse](#toolproviderlistresponse)
| ### [GET] /workspaces/current/tools/builtin #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Builtin tools retrieved successfully | **application/json**: [ToolProviderListResponse](#toolproviderlistresponse)
| ### [GET] /workspaces/current/tools/mcp #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | MCP tools retrieved successfully | **application/json**: [ToolProviderListResponse](#toolproviderlistresponse)
| ### [GET] /workspaces/current/tools/workflow #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| +| 200 | Workflow tools retrieved successfully | **application/json**: [ToolProviderListResponse](#toolproviderlistresponse)
| ### [GET] /workspaces/current/trigger-provider/{provider}/icon #### Parameters @@ -11442,9 +11443,9 @@ Returns permission flags that control workspace features like member invitations #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Trigger provider icon | ### [GET] /workspaces/current/trigger-provider/{provider}/info **Get info for a trigger provider** @@ -11459,7 +11460,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| +| 200 | Trigger provider retrieved successfully | **application/json**: [TriggerProviderApiEntity](#triggerproviderapientity)
| ### [DELETE] /workspaces/current/trigger-provider/{provider}/oauth/client **Remove custom OAuth client configuration** @@ -11474,7 +11475,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 200 | Trigger OAuth client deleted successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [GET] /workspaces/current/trigger-provider/{provider}/oauth/client **Get OAuth client configuration for a provider** @@ -11489,7 +11490,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerOAuthClientResponse](#triggeroauthclientresponse)
| +| 200 | Trigger OAuth client retrieved successfully | **application/json**: [TriggerOAuthClientResponse](#triggeroauthclientresponse)
| ### [POST] /workspaces/current/trigger-provider/{provider}/oauth/client **Configure custom OAuth client for a provider** @@ -11510,7 +11511,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 200 | Trigger OAuth client saved successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [POST] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/build/{subscription_builder_id} **Build a subscription instance for a trigger provider** @@ -11532,7 +11533,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| +| 200 | Trigger subscription builder built successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [POST] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/create **Add a new subscription instance for a trigger provider** @@ -11553,7 +11554,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| +| 200 | Trigger subscription builder created successfully | **application/json**: [TriggerSubscriptionBuilderCreateResponse](#triggersubscriptionbuildercreateresponse)
| ### [GET] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/logs/{subscription_builder_id} **Get the request logs for a subscription instance for a trigger provider** @@ -11569,7 +11570,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| +| 200 | Trigger subscription builder logs retrieved successfully | **application/json**: [TriggerSubscriptionBuilderLogsResponse](#triggersubscriptionbuilderlogsresponse)
| ### [POST] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/update/{subscription_builder_id} **Update a subscription instance for a trigger provider** @@ -11591,7 +11592,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| +| 200 | Trigger subscription builder updated successfully | **application/json**: [SubscriptionBuilderApiEntity](#subscriptionbuilderapientity)
| ### [POST] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/verify-and-update/{subscription_builder_id} **Verify and update a subscription instance for a trigger provider** @@ -11613,7 +11614,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| +| 200 | Trigger subscription builder verified successfully | **application/json**: [TriggerVerificationResponse](#triggerverificationresponse)
| ### [GET] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/{subscription_builder_id} **Get a subscription instance for a trigger provider** @@ -11629,7 +11630,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| +| 200 | Trigger subscription builder retrieved successfully | **application/json**: [SubscriptionBuilderApiEntity](#subscriptionbuilderapientity)
| ### [GET] /workspaces/current/trigger-provider/{provider}/subscriptions/list **List all trigger subscriptions for the current tenant's provider** @@ -11644,7 +11645,8 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| +| 200 | Trigger subscriptions retrieved successfully | **application/json**: [TriggerProviderSubscriptionListResponse](#triggerprovidersubscriptionlistresponse)
| +| 404 | Trigger provider not found | **application/json**: [TriggerProviderErrorResponse](#triggerprovidererrorresponse)
| ### [GET] /workspaces/current/trigger-provider/{provider}/subscriptions/oauth/authorize **Initiate OAuth authorization flow for a trigger provider** @@ -11659,7 +11661,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Authorization URL retrieved successfully | **application/json**: [TriggerOAuthAuthorizeResponse](#triggeroauthauthorizeresponse)
| +| 200 | Trigger OAuth authorization URL generated successfully | **application/json**: [TriggerOAuthAuthorizeResponse](#triggeroauthauthorizeresponse)
| ### [POST] /workspaces/current/trigger-provider/{provider}/subscriptions/verify/{subscription_id} **Verify credentials for an existing subscription (edit mode only)** @@ -11681,7 +11683,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| +| 200 | Trigger subscription verified successfully | **application/json**: [TriggerVerificationResponse](#triggerverificationresponse)
| ### [POST] /workspaces/current/trigger-provider/{subscription_id}/subscriptions/delete **Delete a subscription instance** @@ -11717,7 +11719,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| +| 200 | Trigger subscription updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [GET] /workspaces/current/triggers **List all trigger providers for the current tenant** @@ -11726,7 +11728,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| +| 200 | Trigger providers retrieved successfully | **application/json**: [TriggerProviderListResponse](#triggerproviderlistresponse)
| ### [POST] /workspaces/custom-config #### Request Body @@ -11739,9 +11741,15 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [WorkspaceMutationResponse](#workspacemutationresponse)
| +| 200 | Success | **application/json**: [WorkspaceTenantResultResponse](#workspacetenantresultresponse)
| ### [POST] /workspaces/custom-config/webapp-logo/upload +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"file"**: binary }
| + #### Responses | Code | Description | Schema | @@ -11759,7 +11767,7 @@ Returns permission flags that control workspace features like member invitations | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [WorkspaceMutationResponse](#workspacemutationresponse)
| +| 200 | Success | **application/json**: [WorkspaceTenantResultResponse](#workspacetenantresultresponse)
| ### [POST] /workspaces/switch #### Request Body @@ -11786,9 +11794,9 @@ Returns permission flags that control workspace features like member invitations #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| +| Code | Description | +| ---- | ----------- | +| 200 | Model provider icon | --- ## default @@ -11812,6 +11820,22 @@ Default namespace --- ### Schemas +#### AIModelEntity + +Model class for AI model. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| deprecated | boolean | | No | +| features | [ [ModelFeature](#modelfeature) ] | | No | +| fetch_from | [FetchFrom](#fetchfrom) | | Yes | +| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| model | string | | Yes | +| model_properties | object | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | +| parameter_rules | [ [ParameterRule](#parameterrule) ],
**Default:** | | No | +| pricing | [PriceConfig](#priceconfig) | | No | + #### AIModelEntityResponse | Name | Type | Description | Required | @@ -11928,23 +11952,6 @@ Default namespace | role_name | string | | No | | tenant_id | string | | No | -#### Account - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| avatar | string | | No | -| avatar_url | string | | Yes | -| created_at | integer | | No | -| email | string | | Yes | -| id | string | | Yes | -| interface_language | string | | No | -| interface_theme | string | | No | -| is_password_set | boolean | | Yes | -| last_login_at | integer | | No | -| last_login_ip | string | | No | -| name | string | | Yes | -| timezone | string | | No | - #### AccountAvatarPayload | Name | Type | Description | Required | @@ -12020,17 +12027,41 @@ Default namespace | password | string | | No | | repeat_new_password | string | | Yes | -#### AccountTimezonePayload +#### AccountResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar | string | | No | +| avatar_url | string | | Yes | +| created_at | integer | | No | +| email | string | | Yes | +| id | string | | Yes | +| interface_language | string | | No | +| interface_theme | string | | No | +| is_password_set | boolean | | Yes | +| last_login_at | integer | | No | +| last_login_ip | string | | No | +| name | string | | Yes | +| timezone | string | | No | + +#### AccountTimezonePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | timezone | string | | Yes | -#### AccountWithRole +#### AccountWithRoleListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| accounts | [ [AccountWithRoleResponse](#accountwithroleresponse) ] | | Yes | + +#### AccountWithRoleResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | avatar | string | | No | +| avatar_url | string | | Yes | | created_at | integer | | No | | email | string | | Yes | | id | string | | Yes | @@ -12041,12 +12072,6 @@ Default namespace | roles | [ object ] | | No | | status | string | | Yes | -#### AccountWithRoleList - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| accounts | [ [AccountWithRole](#accountwithrole) ] | | Yes | - #### ActivateCheckQuery | Name | Type | Description | Required | @@ -12095,7 +12120,7 @@ Default namespace | ---- | ---- | ----------- | -------- | | conversation_id | string | | No | | created_at | integer | | No | -| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No | | elapsed_time | number | | No | | exceptions_count | integer | | No | | finished_at | integer | | No | @@ -12229,7 +12254,7 @@ Default namespace | role | string | | No | | site | [Site](#site) | | No | | tags | [ [Tag](#tag) ] | | No | -| tracing | [JSONValue](#jsonvalue) | | No | +| tracing | | | No | | updated_at | integer | | No | | updated_by | string | | No | | use_icon_as_answer_icon | boolean | | No | @@ -13404,6 +13429,27 @@ Soft lifecycle state for Agent records. | ---- | ---- | ----------- | -------- | | AgentStatus | string | Soft lifecycle state for Agent records. | | +#### AgentStrategyProviderEntity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| identity | [AgentStrategyProviderIdentity](#agentstrategyprovideridentity) | | Yes | +| plugin_id | string | The id of the plugin | No | + +#### AgentStrategyProviderIdentity + +Inherits from ToolProviderIdentity, without any additional fields. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | The author of the tool | Yes | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The description of the tool | Yes | +| icon | string | The icon of the tool | Yes | +| icon_dark | string | The dark icon of the tool | No | +| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The label of the tool | Yes | +| name | string | The name of the tool | Yes | +| tags | [ [ToolLabelEnum](#toollabelenum) ] | The tags of the tool | No | + #### AgentSuggestedQuestionsAfterAnswerFeatureConfig | Name | Type | Description | Required | @@ -13429,7 +13475,6 @@ Soft lifecycle state for Agent records. | created_at | integer | | No | | files | [ string ] | | Yes | | id | string | | Yes | -| message_chain_id | string | | No | | message_id | string | | Yes | | observation | string | | No | | position | integer | | Yes | @@ -13501,18 +13546,20 @@ Soft lifecycle state for Agent records. | id | string | | Yes | | question | string | | No | -#### AnnotationCountResponse +#### AnnotationBatchImportResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| count | integer | Number of annotations | Yes | +| error_msg | string | | No | +| job_id | string | | No | +| job_status | string | | No | +| record_count | integer | | No | -#### AnnotationEmbeddingModelResponse +#### AnnotationCountResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| embedding_model_name | string | | No | -| embedding_provider_name | string | | No | +| count | integer | Number of annotations | Yes | #### AnnotationExportList @@ -13555,14 +13602,20 @@ Soft lifecycle state for Agent records. | limit | integer,
**Default:** 20 | Page size | No | | page | integer,
**Default:** 1 | Page number | No | -#### AnnotationJobStatusResponse +#### AnnotationJobStatusDetailResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | error_msg | string | | No | -| job_id | string | | No | -| job_status | string | | No | -| record_count | integer | | No | +| job_id | string | | Yes | +| job_status | string
string | | Yes | + +#### AnnotationJobStatusResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| job_id | string | | Yes | +| job_status | string
string | | Yes | #### AnnotationList @@ -13596,11 +13649,18 @@ Soft lifecycle state for Agent records. | ---- | ---- | ----------- | -------- | | action | string,
**Available values:** "disable", "enable" | *Enum:* `"disable"`, `"enable"` | Yes | +#### AnnotationSettingEmbeddingModelResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| embedding_model_name | string | | No | +| embedding_provider_name | string | | No | + #### AnnotationSettingResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| embedding_model | [AnnotationEmbeddingModelResponse](#annotationembeddingmodelresponse) | | No | +| embedding_model | [AnnotationSettingEmbeddingModelResponse](#annotationsettingembeddingmodelresponse) | | No | | enabled | boolean | | Yes | | id | string | | No | | score_threshold | number | | No | @@ -13658,6 +13718,26 @@ Soft lifecycle state for Agent records. | ---- | ---- | ----------- | -------- | | data | [ [ApiKeyItem](#apikeyitem) ] | | Yes | +#### ApiProviderDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | No | +| custom_disclaimer | string | | No | +| description | string | | No | +| icon | [ToolEmojiIcon](#toolemojiicon) | | Yes | +| labels | [ string ] | | No | +| privacy_policy | string | | No | +| schema | string | | Yes | +| schema_type | [ApiProviderSchemaType](#apiproviderschematype) | | Yes | +| tools | [ [ApiToolBundle](#apitoolbundle) ] | | Yes | + +#### ApiProviderRemoteSchemaResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| schema | string | | Yes | + #### ApiProviderSchemaType Enum class for api provider schema type. @@ -13666,13 +13746,52 @@ Enum class for api provider schema type. | ---- | ---- | ----------- | -------- | | ApiProviderSchemaType | string | Enum class for api provider schema type. | | +#### ApiSchemaParseResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials_schema | [ [ProviderConfig](#providerconfig) ] | | Yes | +| parameters_schema | [ [ApiToolBundle](#apitoolbundle) ] | | Yes | +| schema_type | [ApiProviderSchemaType](#apiproviderschematype) | | Yes | +| warning | object | | Yes | + +#### ApiToolBundle + +This class is used to store the schema information of an api based tool. + such as the url, the method, the parameters, etc. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | | Yes | +| icon | string | | No | +| method | string | | Yes | +| openapi | object | | Yes | +| operation_id | string | | No | +| output_schema | object | | No | +| parameters | [ [ToolParameter](#toolparameter) ] | | No | +| server_url | string | | Yes | +| summary | string | | No | + +#### ApiToolPreviewResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ApiToolPreviewResponse | | | | + +#### ApiToolPreviewResult + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| error | string | | No | +| result | string | | No | + #### ApiToolProviderAddPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | credentials | object | | Yes | | custom_disclaimer | string | | No | -| icon | object | | Yes | +| icon | [ToolEmojiIcon](#toolemojiicon) | | Yes | | labels | [ string ] | | No | | privacy_policy | string | | No | | provider | string | | Yes | @@ -13691,7 +13810,7 @@ Enum class for api provider schema type. | ---- | ---- | ----------- | -------- | | credentials | object | | Yes | | custom_disclaimer | string | | No | -| icon | object | | Yes | +| icon | [ToolEmojiIcon](#toolemojiicon) | | Yes | | labels | [ string ] | | No | | original_provider | string | | Yes | | privacy_policy | string | | No | @@ -13748,7 +13867,7 @@ Enum class for api provider schema type. | name | string | | Yes | | permission_keys | [ string ] | | No | | tags | [ [Tag](#tag) ] | | No | -| tracing | [JSONValue](#jsonvalue) | | No | +| tracing | | | No | | updated_at | integer | | No | | updated_by | string | | No | | use_icon_as_answer_icon | boolean | | No | @@ -13781,7 +13900,7 @@ Enum class for api provider schema type. | permission_keys | [ string ] | | No | | site | [Site](#site) | | No | | tags | [ [Tag](#tag) ] | | No | -| tracing | [JSONValue](#jsonvalue) | | No | +| tracing | | | No | | updated_at | integer | | No | | updated_by | string | | No | | use_icon_as_answer_icon | boolean | | No | @@ -13828,6 +13947,18 @@ Enum class for api provider schema type. | yaml_content | string | | No | | yaml_url | string | | No | +#### AppImportResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | No | +| app_mode | string | | No | +| current_dsl_version | string | | Yes | +| error | string | | No | +| id | string | | Yes | +| imported_dsl_version | string | | No | +| status | [ImportStatus](#importstatus) | | Yes | + #### AppListQuery | Name | Type | Description | Required | @@ -13909,6 +14040,12 @@ AppMCPServer Status Enum | use_icon_as_answer_icon | boolean | | No | | workflow | [WorkflowPartial](#workflowpartial) | | No | +#### AppSelectorScope + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AppSelectorScope | string | | | + #### AppSiteResponse | Name | Type | Description | Required | @@ -13967,7 +14104,7 @@ AppMCPServer Status Enum | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| enabled | boolean | | Yes | +| enabled | boolean | | No | | tracing_provider | string | | No | #### AppVariableConfig @@ -13991,6 +14128,12 @@ AppMCPServer Status Enum | ---- | ---- | ----------- | -------- | | text | string | | Yes | +#### AuthorizedCategory + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AuthorizedCategory | string | | | + #### AutoDisableLogsResponse | Name | Type | Description | Required | @@ -13998,6 +14141,12 @@ AppMCPServer Status Enum | count | integer | | Yes | | document_ids | [ string ] | | Yes | +#### AvailableModelListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ProviderWithModelsResponse](#providerwithmodelsresponse) ] | | Yes | + #### AvatarUrlResponse | Name | Type | Description | Required | @@ -14061,6 +14210,21 @@ AppMCPServer Status Enum | query | string | | Yes | | retrieval_setting | [BedrockRetrievalSetting](#bedrockretrievalsetting) | | Yes | +#### BedrockRetrievalRecordResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| metadata | object | | No | +| score | number | | Yes | +| title | string | | No | + +#### BedrockRetrievalResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| records | [ [BedrockRetrievalRecordResponse](#bedrockretrievalrecordresponse) ] | | Yes | + #### BedrockRetrievalSetting Retrieval settings for Amazon Bedrock knowledge base queries. @@ -14111,6 +14275,16 @@ Retrieval settings for Amazon Bedrock knowledge base queries. | ---- | ---- | ----------- | -------- | | id | string | | Yes | +#### BuiltinProviderOAuthClientSchemaResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_params | object | | No | +| is_oauth_custom_client_enabled | boolean | | Yes | +| is_system_oauth_params_exists | boolean | | Yes | +| redirect_uri | string | | Yes | +| schema | [ [ProviderConfig](#providerconfig) ] | | Yes | + #### BuiltinToolAddPayload | Name | Type | Description | Required | @@ -14512,13 +14686,13 @@ Enum class for configurate method of provider model. | admin_feedback_stats | [FeedbackStat](#feedbackstat) | | No | | annotation | [ConversationAnnotation](#conversationannotation) | | No | | created_at | integer | | No | -| first_message | [SimpleMessageDetail](#simplemessagedetail) | | No | | from_account_id | string | | No | | from_account_name | string | | No | | from_end_user_id | string | | No | | from_end_user_session_id | string | | No | | from_source | string | | Yes | | id | string | | Yes | +| message | [SimpleMessageDetail](#simplemessagedetail) | | No | | model_config | [SimpleModelConfig](#simplemodelconfig) | | No | | read_at | integer | | No | | status | string | | Yes | @@ -14540,8 +14714,8 @@ Enum class for configurate method of provider model. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | annotation_create_account | [SimpleAccount](#simpleaccount) | | No | +| annotation_id | string | | Yes | | created_at | integer | | No | -| id | string | | Yes | #### ConversationDetail @@ -14582,11 +14756,11 @@ Enum class for configurate method of provider model. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | created_at | integer | | No | -| first_message | [MessageDetail](#messagedetail) | | No | | from_account_id | string | | No | | from_end_user_id | string | | No | | from_source | string | | Yes | | id | string | | Yes | +| message | [MessageDetail](#messagedetail) | | No | | model_config | [ModelConfig](#modelconfig) | | No | | status | string | | Yes | @@ -14594,10 +14768,10 @@ Enum class for configurate method of provider model. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| has_next | boolean | | Yes | -| items | [ [Conversation](#conversation) ] | | Yes | +| data | [ [Conversation](#conversation) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | | page | integer | | Yes | -| per_page | integer | | Yes | | total | integer | | Yes | #### ConversationRenamePayload @@ -14650,7 +14824,7 @@ Enum class for configurate method of provider model. | read_at | integer | | No | | status | string | | Yes | | status_count | [StatusCount](#statuscount) | | No | -| summary_or_query | string | | Yes | +| summary | string | | Yes | | updated_at | integer | | No | | user_feedback_stats | [FeedbackStat](#feedbackstat) | | No | @@ -14658,10 +14832,10 @@ Enum class for configurate method of provider model. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| has_next | boolean | | Yes | -| items | [ [ConversationWithSummary](#conversationwithsummary) ] | | Yes | +| data | [ [ConversationWithSummary](#conversationwithsummary) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | | page | integer | | Yes | -| per_page | integer | | Yes | | total | integer | | Yes | #### ConvertToWorkflowPayload @@ -14834,10 +15008,10 @@ Model class for provider custom model configuration. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| currency | string | | Yes | +| currency | string | | No | | date | string | | Yes | -| token_count | integer | | Yes | -| total_price | string
number | | Yes | +| token_count | integer | | No | +| total_price | string | | No | #### DailyTokenCostStatisticResponse @@ -14851,12 +15025,6 @@ Model class for provider custom model configuration. | ---- | ---- | ----------- | -------- | | info_list | [InfoList](#infolist) | | Yes | -#### DataSourceContentPreviewResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| DataSourceContentPreviewResponse | | | | - #### DataSourceIntegrateIconResponse | Name | Type | Description | Required | @@ -14930,47 +15098,6 @@ Model class for provider custom model configuration. | permission | [PermissionEnum](#permissionenum) | | No | | provider | string,
**Default:** vendor | | No | -#### DatasetDetail - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| app_count | integer | | No | -| author_name | string | | No | -| built_in_field_enabled | boolean | | No | -| chunk_structure | string | | No | -| created_at | long | | No | -| created_by | string | | No | -| data_source_type | string | | No | -| description | string | | No | -| doc_form | string | | No | -| doc_metadata | [ [DatasetDocMetadata](#datasetdocmetadata) ] | | No | -| document_count | integer | | No | -| embedding_available | boolean | | No | -| embedding_model | string | | No | -| embedding_model_provider | string | | No | -| enable_api | boolean | | No | -| external_knowledge_info | [ExternalKnowledgeInfo](#externalknowledgeinfo) | | No | -| external_retrieval_model | [ExternalRetrievalModel](#externalretrievalmodel) | | No | -| icon_info | [DatasetIconInfo](#dataseticoninfo) | | No | -| id | string | | No | -| indexing_technique | string | | No | -| is_multimodal | boolean | | No | -| is_published | boolean | | No | -| name | string | | No | -| permission | string | | No | -| permission_keys | [ string ] | | No | -| pipeline_id | string | | No | -| provider | string | | No | -| retrieval_model_dict | [DatasetRetrievalModel](#datasetretrievalmodel) | | No | -| runtime_mode | string | | No | -| summary_index_setting | [_AnonymousInlineModel_b1954337d565](#_anonymousinlinemodel_b1954337d565) | | No | -| tags | [ [Tag](#tag) ] | | No | -| total_available_documents | integer | | No | -| total_documents | integer | | No | -| updated_at | long | | No | -| updated_by | string | | No | -| word_count | integer | | No | - #### DatasetDetailResponse | Name | Type | Description | Required | @@ -15056,14 +15183,6 @@ Model class for provider custom model configuration. | updated_by | string | | Yes | | word_count | integer | | Yes | -#### DatasetDocMetadata - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| id | string | | No | -| name | string | | No | -| type | string | | No | - #### DatasetDocMetadataResponse | Name | Type | Description | Required | @@ -15089,15 +15208,6 @@ Model class for provider custom model configuration. | score_threshold_enabled | boolean | | No | | top_k | integer | | Yes | -#### DatasetIconInfo - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| icon | string | | No | -| icon_background | string | | No | -| icon_type | string | | No | -| icon_url | string | | No | - #### DatasetIconInfoResponse | Name | Type | Description | Required | @@ -15107,12 +15217,6 @@ Model class for provider custom model configuration. | icon_type | string | | No | | icon_url | string | | No | -#### DatasetKeywordSetting - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| keyword_weight | number | | No | - #### DatasetKeywordSettingResponse | Name | Type | Description | Required | @@ -15250,13 +15354,6 @@ Model class for provider custom model configuration. | page | integer | | Yes | | total | integer | | Yes | -#### DatasetRerankingModel - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| reranking_model_name | string | | No | -| reranking_provider_name | string | | No | - #### DatasetRerankingModelResponse | Name | Type | Description | Required | @@ -15277,19 +15374,6 @@ Model class for provider custom model configuration. | name | string | | Yes | | permission | string | | No | -#### DatasetRetrievalModel - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| reranking_enable | boolean | | No | -| reranking_mode | string | | No | -| reranking_model | [DatasetRerankingModel](#datasetrerankingmodel) | | No | -| score_threshold | number | | No | -| score_threshold_enabled | boolean | | No | -| search_method | string | | No | -| top_k | integer | | No | -| weights | [DatasetWeightedScore](#datasetweightedscore) | | No | - #### DatasetRetrievalModelResponse | Name | Type | Description | Required | @@ -15339,14 +15423,6 @@ Model class for provider custom model configuration. | retrieval_model | object | | No | | summary_index_setting | object | | No | -#### DatasetVectorSetting - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| embedding_model_name | string | | No | -| embedding_provider_name | string | | No | -| vector_weight | number | | No | - #### DatasetVectorSettingResponse | Name | Type | Description | Required | @@ -15355,14 +15431,6 @@ Model class for provider custom model configuration. | embedding_provider_name | string | | No | | vector_weight | number | | No | -#### DatasetWeightedScore - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| keyword_setting | [DatasetKeywordSetting](#datasetkeywordsetting) | | No | -| vector_setting | [DatasetVectorSetting](#datasetvectorsetting) | | No | -| weight_type | string | | No | - #### DatasetWeightedScoreResponse | Name | Type | Description | Required | @@ -15377,32 +15445,43 @@ Model class for provider custom model configuration. | ---- | ---- | ----------- | -------- | | credential_id | string | | Yes | +#### DatasourceCredentialListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | [ [DatasourceCredentialResponse](#datasourcecredentialresponse) ] | | Yes | + #### DatasourceCredentialPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| credentials | object | | Yes | +| credentials | object | Plugin-defined credential parameters. The schema is declared by the datasource provider. | Yes | | name | string | | No | -#### DatasourceCredentialUpdatePayload +#### DatasourceCredentialResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| credential_id | string | | Yes | -| credentials | object | | No | -| name | string | | No | +| avatar_url | string | | Yes | +| credential | object | Obfuscated plugin-defined credential parameters from the datasource provider. | Yes | +| id | string | | Yes | +| is_default | boolean | | Yes | +| name | string | | Yes | +| type | string | | Yes | -#### DatasourceCredentialsResponse +#### DatasourceCredentialUpdatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| result | | | Yes | +| credential_id | string | | Yes | +| credentials | object | Plugin-defined credential parameters. The schema is declared by the datasource provider. | No | +| name | string | | No | #### DatasourceCustomClientPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| client_params | object | | No | +| client_params | object | Plugin-defined OAuth client parameters. The schema is declared by the datasource provider. | No | | enable_oauth_custom_client | boolean | | No | #### DatasourceDefaultPayload @@ -15411,6 +15490,25 @@ Model class for provider custom model configuration. | ---- | ---- | ----------- | -------- | | id | string | | Yes | +#### DatasourceEntity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | [I18nObject](#i18nobject) | The label of the datasource | Yes | +| identity | [DatasourceIdentity](#datasourceidentity) | | Yes | +| output_schema | object | | No | +| parameters | [ [DatasourceParameter](#datasourceparameter) ] | | No | + +#### DatasourceIdentity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | The author of the datasource | Yes | +| icon | string | | No | +| label | [I18nObject](#i18nobject) | The label of the datasource | Yes | +| name | string | The name of the datasource | Yes | +| provider | string | The provider of the datasource | Yes | + #### DatasourceNodeRunPayload | Name | Type | Description | Required | @@ -15434,11 +15532,119 @@ Model class for provider custom model configuration. | error | string | Error message from OAuth provider | No | | state | string | OAuth state parameter | No | -#### DatasourceUpdateNamePayload +#### DatasourceOAuthSchemaResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| credential_id | string | | Yes | +| client_schema | [ [ProviderConfig](#providerconfig) ] | | Yes | +| credentials_schema | [ [ProviderConfig](#providerconfig) ] | | Yes | +| is_oauth_custom_client_enabled | boolean | | Yes | +| is_system_oauth_params_exists | boolean | | Yes | +| oauth_custom_client_params | object | Masked plugin-defined OAuth client parameters, when configured for the tenant. | Yes | +| redirect_uri | string | | Yes | + +#### DatasourceParameter + +Overrides type + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_generate | [PluginParameterAutoGenerate](#pluginparameterautogenerate) | | No | +| default | number
integer
string
boolean
[ object ]
object | | No | +| description | [I18nObject](#i18nobject) | The description of the parameter | Yes | +| label | [I18nObject](#i18nobject) | The label presented to the user | Yes | +| max | number
integer | | No | +| min | number
integer | | No | +| name | string | The name of the parameter | Yes | +| options | [ [PluginParameterOption](#pluginparameteroption) ] | | No | +| placeholder | [I18nObject](#i18nobject) | The placeholder presented to the user | No | +| precision | integer | | No | +| required | boolean | | No | +| scope | string | | No | +| template | [PluginParameterTemplate](#pluginparametertemplate) | | No | +| type | [DatasourceParameterType](#datasourceparametertype) | The type of the parameter | Yes | + +#### DatasourceParameterType + +removes TOOLS_SELECTOR from PluginParameterType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DatasourceParameterType | string | removes TOOLS_SELECTOR from PluginParameterType | | + +#### DatasourcePluginListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DatasourcePluginListResponse | array | | | + +#### DatasourceProviderAuthListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | [ [DatasourceProviderAuthResponse](#datasourceproviderauthresponse) ] | | Yes | + +#### DatasourceProviderAuthResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | | Yes | +| credential_schema | [ [ProviderConfig](#providerconfig) ] | | Yes | +| credentials_list | [ [DatasourceCredentialResponse](#datasourcecredentialresponse) ] | | Yes | +| description | [I18nObject](#i18nobject) | | Yes | +| icon | string | | Yes | +| label | [I18nObject](#i18nobject) | | Yes | +| name | string | | Yes | +| oauth_schema | [DatasourceOAuthSchemaResponse](#datasourceoauthschemaresponse) | | Yes | +| plugin_id | string | | Yes | +| plugin_unique_identifier | string | | Yes | +| provider | string | | Yes | + +#### DatasourceProviderEntity + +Datasource provider entity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials_schema | [ [ProviderConfig](#providerconfig) ] | | No | +| identity | [DatasourceProviderIdentity](#datasourceprovideridentity) | | Yes | +| oauth_schema | [OAuthSchema](#oauthschema) | | No | +| provider_type | [DatasourceProviderType](#datasourceprovidertype) | | Yes | + +#### DatasourceProviderEntityWithPlugin + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials_schema | [ [ProviderConfig](#providerconfig) ] | | No | +| datasources | [ [DatasourceEntity](#datasourceentity) ] | | No | +| identity | [DatasourceProviderIdentity](#datasourceprovideridentity) | | Yes | +| oauth_schema | [OAuthSchema](#oauthschema) | | No | +| provider_type | [DatasourceProviderType](#datasourceprovidertype) | | Yes | + +#### DatasourceProviderIdentity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | The author of the tool | Yes | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The description of the tool | Yes | +| icon | string | The icon of the tool | Yes | +| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The label of the tool | Yes | +| name | string | The name of the tool | Yes | +| tags | [ [ToolLabelEnum](#toollabelenum) ] | The tags of the tool | No | + +#### DatasourceProviderType + +Enum class for datasource provider + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DatasourceProviderType | string | Enum class for datasource provider | | + +#### DatasourceUpdateNamePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | | name | string | | Yes | #### DatasourceVariablesPayload @@ -15548,18 +15754,6 @@ Per-output retry configuration that mirrors ``graphon.RetryConfig`` shape. | ---- | ---- | ----------- | -------- | | q | string | | No | -#### DefaultBlockConfigResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| DefaultBlockConfigResponse | object | | | - -#### DefaultBlockConfigsResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| DefaultBlockConfigsResponse | array | | | - #### DefaultModelDataResponse | Name | Type | Description | Required | @@ -15598,6 +15792,42 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | document_ids | [ string (uuid) ] | List of document IDs to include in the ZIP download. | Yes | +#### DocumentDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| archived | boolean | | No | +| average_segment_length | number | | No | +| completed_at | integer | | No | +| created_at | integer | | No | +| created_by | string | | No | +| created_from | string | | No | +| data_source_detail_dict | | | No | +| data_source_info | | | No | +| data_source_type | string | | No | +| dataset_process_rule | | | No | +| dataset_process_rule_id | string | | No | +| disabled_at | integer | | No | +| disabled_by | string | | No | +| display_status | string | | No | +| doc_form | string | | No | +| doc_language | string | | No | +| doc_metadata | [ [DocumentMetadataResponse](#documentmetadataresponse) ] | | No | +| doc_type | string | | No | +| document_process_rule | | | No | +| enabled | boolean | | No | +| error | string | | No | +| hit_count | integer | | No | +| id | string | | Yes | +| indexing_latency | number | | No | +| indexing_status | string | | No | +| name | string | | No | +| need_summary | boolean | | No | +| position | integer | | No | +| segment_count | integer | | No | +| tokens | integer | | No | +| updated_at | integer | | No | + #### DocumentMetadataOperation | Name | Type | Description | Required | @@ -15622,6 +15852,15 @@ Request payload for bulk downloading documents as a zip archive. | doc_metadata | | | No | | doc_type | string | | No | +#### DocumentPipelineExecutionLogResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasource_info | [JsonValue](#jsonvalue) | | No | +| datasource_node_id | string | | No | +| datasource_type | string | | No | +| input_data | [JsonValue](#jsonvalue) | | No | + #### DocumentRenamePayload | Name | Type | Description | Required | @@ -15686,6 +15925,14 @@ Request payload for bulk downloading documents as a zip archive. | stopped_at | integer | | Yes | | total_segments | integer | | No | +#### DocumentSummaryStatusResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| summaries | [ [SummaryEntryResponse](#summaryentryresponse) ] | | Yes | +| summary_status | [SummaryStatusResponse](#summarystatusresponse) | | Yes | +| total_segments | integer | | Yes | + #### DocumentWithSegmentsListResponse | Name | Type | Description | Required | @@ -15768,12 +16015,6 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | node_id | string | | Yes | -#### DraftWorkflowTriggerRunRequest - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| node_id | string | Node ID | Yes | - #### EducationActivatePayload | Name | Type | Description | Required | @@ -15782,12 +16023,6 @@ Request payload for bulk downloading documents as a zip archive. | role | string | | Yes | | token | string | | Yes | -#### EducationActivateResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| EducationActivateResponse | object | | | - #### EducationAutocompleteQuery | Name | Type | Description | Required | @@ -15883,11 +16118,11 @@ Request payload for bulk downloading documents as a zip archive. | email | string | | Yes | | token | string | | Yes | -#### EmptyObjectResponse +#### Endpoint | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| EmptyObjectResponse | object | | | +| enabled | boolean | | No | #### EndpointCreatePayload @@ -15897,29 +16132,32 @@ Request payload for bulk downloading documents as a zip archive. | plugin_unique_identifier | string | | Yes | | settings | object | | Yes | -#### EndpointCreateResponse +#### EndpointDeclaration -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| success | boolean | Operation success | Yes | - -#### EndpointDeleteResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| success | boolean | Operation success | Yes | - -#### EndpointDisableResponse +declaration of an endpoint | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| success | boolean | Operation success | Yes | +| hidden | boolean | | No | +| method | string | | Yes | +| path | string | | Yes | -#### EndpointEnableResponse +#### EndpointEntityWithInstance | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| success | boolean | Operation success | Yes | +| created_at | dateTime | | Yes | +| declaration | [EndpointProviderDeclaration](#endpointproviderdeclaration) | | No | +| enabled | boolean | | Yes | +| expired_at | dateTime | | Yes | +| hook_id | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | +| plugin_id | string | | Yes | +| settings | object | | Yes | +| tenant_id | string | | Yes | +| updated_at | dateTime | | Yes | +| url | string | | Yes | #### EndpointIdPayload @@ -15946,20 +16184,30 @@ Request payload for bulk downloading documents as a zip archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| endpoints | [ object ] | Endpoint information | Yes | +| endpoints | [ [EndpointEntityWithInstance](#endpointentitywithinstance) ] | Endpoint information | Yes | -#### EndpointUpdatePayload +#### EndpointProviderDeclaration + +declaration of an endpoint group + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| endpoints | [ [EndpointDeclaration](#endpointdeclaration) ] | | No | +| settings | [ [ProviderConfig](#providerconfig) ] | | No | + +#### EndpointSettingsPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | name | string | | Yes | | settings | object | | Yes | -#### EndpointUpdateResponse +#### EndpointUpdatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| success | boolean | Operation success | Yes | +| name | string | | Yes | +| settings | object | | Yes | #### EnvSuggestion @@ -15969,39 +16217,79 @@ Request payload for bulk downloading documents as a zip archive. | reason | string | | No | | secret_likely | boolean | | No | -#### EnvironmentVariableItemResponse +#### EnvironmentVariableUpdatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| description | string | | No | -| editable | boolean | | Yes | -| edited | boolean | | Yes | -| id | string | | Yes | -| name | string | | Yes | -| selector | [ string ] | | Yes | -| type | string | | Yes | -| value | | | Yes | -| value_type | string | | Yes | -| visible | boolean | | Yes | +| environment_variables | [ object ] | Environment variables for the draft workflow | Yes | -#### EnvironmentVariableListResponse +#### ErrorDocsResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| items | [ [EnvironmentVariableItemResponse](#environmentvariableitemresponse) ] | | Yes | +| data | [ [DocumentStatusResponse](#documentstatusresponse) ] | | Yes | +| total | integer | | Yes | -#### EnvironmentVariableUpdatePayload +#### EventApiEntity | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| environment_variables | [ object ] | Environment variables for the draft workflow | Yes | +| description | [I18nObject](#i18nobject) | The description of the trigger | Yes | +| identity | [EventIdentity](#eventidentity) | The identity of the trigger | Yes | +| name | string | The name of the trigger | Yes | +| output_schema | object | The output schema of the trigger | Yes | +| parameters | [ [EventParameter](#eventparameter) ] | The parameters of the trigger | Yes | -#### ErrorDocsResponse +#### EventEntity + +The configuration of an event | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [DocumentStatusResponse](#documentstatusresponse) ] | | Yes | -| total | integer | | Yes | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The description of the event | Yes | +| identity | [EventIdentity](#eventidentity) | | Yes | +| output_schema | object | The output schema that this event produces | No | +| parameters | [ [EventParameter](#eventparameter) ] | The parameters of the event | No | + +#### EventIdentity + +The identity of the event + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | The author of the event | Yes | +| label | [I18nObject](#i18nobject) | The label of the event | Yes | +| name | string | The name of the event | Yes | +| provider | string | The provider of the event | No | + +#### EventParameter + +The parameter of the event + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_generate | [PluginParameterAutoGenerate](#pluginparameterautogenerate) | The auto generate of the parameter | No | +| default | integer
number
string
[ object ] | | No | +| description | [I18nObject](#i18nobject) | | No | +| label | [I18nObject](#i18nobject) | The label presented to the user | Yes | +| max | number
integer | | No | +| min | number
integer | | No | +| multiple | boolean | Whether the parameter is multiple select, only valid for select or dynamic-select type | No | +| name | string | The name of the parameter | Yes | +| options | [ [PluginParameterOption](#pluginparameteroption) ] | | No | +| precision | integer | | No | +| required | boolean | | No | +| scope | string | | No | +| template | [PluginParameterTemplate](#pluginparametertemplate) | The template of the parameter | No | +| type | [EventParameterType](#eventparametertype) | | Yes | + +#### EventParameterType + +The type of the parameter + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| EventParameterType | string | The type of the parameter | | #### EventStreamResponse @@ -16075,65 +16363,64 @@ Request payload for bulk downloading documents as a zip archive. | metadata_filtering_conditions | object | | No | | query | string | | Yes | -#### ExternalKnowledgeApiListResponse +#### ExternalHitTestingQueryResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [ExternalKnowledgeApiResponse](#externalknowledgeapiresponse) ] | | Yes | -| has_more | boolean | | Yes | -| limit | integer | | Yes | -| page | integer | | Yes | -| total | integer | | Yes | +| content | string | | Yes | -#### ExternalKnowledgeApiPayload +#### ExternalHitTestingRecordResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| name | string | | Yes | -| settings | object | | Yes | +| content | string | | No | +| metadata | object | | No | +| score | number | | No | +| title | string | | No | -#### ExternalKnowledgeApiResponse +#### ExternalHitTestingResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | string | | Yes | -| created_by | string | | Yes | -| dataset_bindings | [ [ExternalKnowledgeDatasetBindingResponse](#externalknowledgedatasetbindingresponse) ] | | No | -| description | string | | Yes | -| id | string | | Yes | -| name | string | | Yes | -| settings | object | | No | -| tenant_id | string | | Yes | +| query | [ExternalHitTestingQueryResponse](#externalhittestingqueryresponse) | | Yes | +| records | [ [ExternalHitTestingRecordResponse](#externalhittestingrecordresponse) ] | | Yes | -#### ExternalKnowledgeDatasetBindingResponse +#### ExternalKnowledgeApiBindingResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | id | string | | Yes | | name | string | | Yes | -#### ExternalKnowledgeInfo +#### ExternalKnowledgeApiListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| external_knowledge_api_endpoint | string | | No | -| external_knowledge_api_id | string | | No | -| external_knowledge_api_name | string | | No | -| external_knowledge_id | string | | No | +| data | [ [ExternalKnowledgeApiResponse](#externalknowledgeapiresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | -#### ExternalRetrievalModel +#### ExternalKnowledgeApiPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| score_threshold | number | | No | -| score_threshold_enabled | boolean | | No | -| top_k | integer | | No | +| name | string | | Yes | +| settings | object | | Yes | -#### ExternalRetrievalTestResponse +#### ExternalKnowledgeApiResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| ExternalRetrievalTestResponse | object
[ object ] | | | +| created_at | string | | Yes | +| created_by | string | | Yes | +| dataset_bindings | [ [ExternalKnowledgeApiBindingResponse](#externalknowledgeapibindingresponse) ] | | Yes | +| description | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | +| settings | object | | Yes | +| tenant_id | string | | Yes | #### FeatureModel @@ -16359,12 +16646,6 @@ Enum class for form type. | ---- | ---- | ----------- | -------- | | document_list | [ string ] | | Yes | -#### GeneratedAppResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| GeneratedAppResponse | | | | - #### GeneratorResponse | Name | Type | Description | Required | @@ -16375,10 +16656,10 @@ Enum class for form type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| github_plugin_unique_identifier | string | | Yes | -| package | string | | Yes | +| packages | string | | Yes | +| release | string | | Yes | | repo | string | | Yes | -| version | string | | Yes | +| repo_address | string | | Yes | #### HitTestingChildChunk @@ -16488,6 +16769,11 @@ Enum class for form type. | delivery_method_id | string | Delivery method ID | Yes | | inputs | object | Values used to fill missing upstream variables referenced in form_content | No | +#### HumanInputDeliveryTestResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | + #### HumanInputFormDefinition | Name | Type | Description | Required | @@ -16513,12 +16799,13 @@ Enum class for form type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| actions | [ object ] | | No | -| display_in_ui | boolean | | No | -| expiration_time | integer | | No | +| TYPE | string,
**Default:** human_input_required | | No | +| actions | [ [HumanInputUserActionResponse](#humaninputuseractionresponse) ] | | No | +| display_in_ui | boolean | Always false for draft preview responses. | No | +| expiration_time | integer | Always null for draft preview responses. | No | | form_content | string | | Yes | | form_id | string | | Yes | -| form_token | string | | No | +| form_token | string | Always null for draft preview responses. | No | | inputs | [ object ] | | No | | node_id | string | | Yes | | node_title | string | | Yes | @@ -16543,12 +16830,6 @@ Enum class for form type. | form_inputs | object | Values the user provides for the form's own fields | Yes | | inputs | object | Values used to fill missing upstream variables referenced in form_content | Yes | -#### HumanInputFormSubmitResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| HumanInputFormSubmitResponse | object | | | - #### HumanInputPauseTypeResponse | Name | Type | Description | Required | @@ -16557,6 +16838,14 @@ Enum class for form type. | form_id | string | | Yes | | type | string | | Yes | +#### HumanInputUserActionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| button_style | string,
**Default:** default | | No | +| id | string | | Yes | +| title | string | | Yes | + #### I18nObject Model class for i18n object. @@ -16639,30 +16928,18 @@ Query parameter for including secret variables in export. | info_list | object | | Yes | | process_rule | object | | Yes | -#### IndexingEstimatePreviewItemResponse +#### IndexingEstimateResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| child_chunks | [ string ] | | No | -| content | string | | Yes | -| summary | string | | No | +| currency | string | | Yes | +| preview | [ [PreviewDetail](#previewdetail) ] | | Yes | +| qa_preview | [ [QAPreviewDetail](#qapreviewdetail) ] | | No | +| tokens | integer | | Yes | +| total_price | number
integer | | Yes | +| total_segments | integer | | Yes | -#### IndexingEstimateQaPreviewItemResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| answer | string | | Yes | -| question | string | | Yes | - -#### IndexingEstimateResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| preview | [ [IndexingEstimatePreviewItemResponse](#indexingestimatepreviewitemresponse) ] | | Yes | -| qa_preview | [ [IndexingEstimateQaPreviewItemResponse](#indexingestimateqapreviewitemresponse) ] | | No | -| total_segments | integer | | Yes | - -#### InfoList +#### InfoList | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -16782,7 +17059,7 @@ Input field definition for snippet parameters. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| JSONValue | string
integer
number
boolean
object
[ object ] | | | +| JSONValue | | | | #### JSONValueType @@ -16828,6 +17105,17 @@ Enum class for large language model mode. | ---- | ---- | ----------- | -------- | | LLMMode | string | Enum class for large language model mode. | | +#### LatestPluginCache + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| alternative_plugin_id | string | | Yes | +| deprecated_reason | string | | Yes | +| plugin_id | string | | Yes | +| status | string | | Yes | +| unique_identifier | string | | Yes | +| version | string | | Yes | + #### LearnDifyAppListResponse | Name | Type | Description | Required | @@ -16919,6 +17207,20 @@ Enum class for large language model mode. | authorization_code | string | | No | | provider_id | string | | Yes | +#### MCPAuthResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| authorization_url | string | | No | +| result | string | | No | + +#### MCPAuthentication + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | | Yes | +| client_secret | string | | No | + #### MCPCallbackQuery | Name | Type | Description | Required | @@ -16926,12 +17228,19 @@ Enum class for large language model mode. | code | string | | Yes | | state | string | | Yes | +#### MCPConfiguration + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| sse_read_timeout | number,
**Default:** 300 | | No | +| timeout | number,
**Default:** 30 | | No | + #### MCPProviderCreatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| authentication | object | | No | -| configuration | object | | No | +| authentication | [MCPAuthentication](#mcpauthentication) | | No | +| configuration | [MCPConfiguration](#mcpconfiguration) | | No | | headers | object | | No | | icon | string | | Yes | | icon_background | string | | No | @@ -16951,8 +17260,8 @@ Enum class for large language model mode. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| authentication | object | | No | -| configuration | object | | No | +| authentication | [MCPAuthentication](#mcpauthentication) | | No | +| configuration | [MCPConfiguration](#mcpconfiguration) | | No | | headers | object | | No | | icon | string | | Yes | | icon_background | string | | No | @@ -16983,15 +17292,16 @@ Enum class for large language model mode. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| marketplace_plugin_unique_identifier | string | | Yes | -| version | string | | No | +| organization | string | | Yes | +| plugin | string | | Yes | +| version | string | | Yes | -#### MemberActionTenantResponse +#### MemberActionResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | result | string | | Yes | -| tenant_id | string | | Yes | +| tenant_id | string | | No | #### MemberBindingsResponse @@ -17051,6 +17361,7 @@ Enum class for large language model mode. | agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes | | annotation | [ConversationAnnotation](#conversationannotation) | | No | | annotation_hit_history | [ConversationAnnotationHitHistory](#conversationannotationhithistory) | | No | +| answer | string | | Yes | | answer_tokens | integer | | Yes | | conversation_id | string | | Yes | | created_at | integer | | No | @@ -17063,12 +17374,11 @@ Enum class for large language model mode. | inputs | object | | Yes | | message | [JSONValue](#jsonvalue) | | Yes | | message_files | [ [MessageFile](#messagefile) ] | | Yes | -| message_metadata_dict | [JSONValue](#jsonvalue) | | Yes | | message_tokens | integer | | Yes | +| metadata | [JSONValue](#jsonvalue) | | Yes | | parent_message_id | string | | No | | provider_response_latency | number | | Yes | | query | string | | Yes | -| re_sign_file_url_answer | string | | Yes | | status | string | | Yes | | workflow_run_id | string | | No | @@ -17076,28 +17386,28 @@ Enum class for large language model mode. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| agent_thoughts | [ [AgentThought](#agentthought) ] | | No | +| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes | | annotation | [ConversationAnnotation](#conversationannotation) | | No | | annotation_hit_history | [ConversationAnnotationHitHistory](#conversationannotationhithistory) | | No | -| answer_tokens | integer | | No | +| answer | string | | Yes | +| answer_tokens | integer | | Yes | | conversation_id | string | | Yes | | created_at | integer | | No | | error | string | | No | | extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | No | -| feedbacks | [ [Feedback](#feedback) ] | | No | +| feedbacks | [ [Feedback](#feedback) ] | | Yes | | from_account_id | string | | No | | from_end_user_id | string | | No | | from_source | string | | Yes | | id | string | | Yes | | inputs | object | | Yes | -| message | [JSONValue](#jsonvalue) | | No | -| message_files | [ [MessageFile](#messagefile) ] | | No | -| message_metadata_dict | [JSONValue](#jsonvalue) | | No | -| message_tokens | integer | | No | +| message | [JSONValue](#jsonvalue) | | Yes | +| message_files | [ [MessageFile](#messagefile) ] | | Yes | +| message_tokens | integer | | Yes | +| metadata | [JSONValue](#jsonvalue) | | Yes | | parent_message_id | string | | No | -| provider_response_latency | number | | No | +| provider_response_latency | number | | Yes | | query | string | | Yes | -| re_sign_file_url_answer | string | | Yes | | status | string | | Yes | | workflow_run_id | string | | No | @@ -17139,6 +17449,13 @@ Enum class for large language model mode. | first_id | string | The ID of the first chat record on the current page. Omit this value to fetch the latest messages; for subsequent pages, use the first message ID from the current list to fetch older messages. | No | | limit | integer,
**Default:** 20 | Number of chat history messages to return per request. | No | +#### Meta + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| minimum_dify_version | string | | No | +| version | string | | No | + #### MetadataArgs | Name | Type | Description | Required | @@ -17177,14 +17494,46 @@ Metadata operation data | ---- | ---- | ----------- | -------- | | name | string | New metadata field name. | Yes | +#### Model + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | | No | +| llm | boolean | | No | +| moderation | boolean | | No | +| rerank | boolean | | No | +| speech2text | boolean | | No | +| text_embedding | boolean | | No | +| tts | boolean | | No | + #### ModelConfig | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| completion_params | object | | No | -| mode | [LLMMode](#llmmode) | | Yes | -| name | string | | Yes | -| provider | string | | Yes | +| agent_mode | | | No | +| annotation_reply | | | No | +| chat_prompt_config | | | No | +| completion_prompt_config | | | No | +| created_at | integer | | No | +| created_by | string | | No | +| dataset_configs | | | No | +| dataset_query_variable | string | | No | +| external_data_tools | | | No | +| file_upload | | | No | +| model | | | No | +| more_like_this | | | No | +| opening_statement | string | | No | +| pre_prompt | string | | No | +| prompt_type | string | | No | +| retriever_resource | | | No | +| sensitive_word_avoidance | | | No | +| speech_to_text | | | No | +| suggested_questions | | | No | +| suggested_questions_after_answer | | | No | +| text_to_speech | | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| user_input_form | | | No | #### ModelConfigPartial @@ -17192,7 +17541,7 @@ Metadata operation data | ---- | ---- | ----------- | -------- | | created_at | integer | | No | | created_by | string | | No | -| model | [JSONValue](#jsonvalue) | | No | +| model | | | No | | pre_prompt | string | | No | | updated_at | integer | | No | | updated_by | string | | No | @@ -17214,22 +17563,15 @@ Metadata operation data | text_to_speech | object | Text to speech configuration | No | | tools | [ object ] | Available tools | No | -#### ModelCredentialLoadBalancingResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| configs | [ object ] | | No | -| enabled | boolean | | Yes | - #### ModelCredentialResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | available_credentials | [ [CredentialConfiguration](#credentialconfiguration) ] | | Yes | -| credentials | object | | No | +| credentials | object | | Yes | | current_credential_id | string | | No | | current_credential_name | string | | No | -| load_balancing | [ModelCredentialLoadBalancingResponse](#modelcredentialloadbalancingresponse) | | Yes | +| load_balancing | [ModelLoadBalancingResponse](#modelloadbalancingresponse) | | Yes | #### ModelCredentialSchema @@ -17240,22 +17582,34 @@ Model class for model credential schema. | credential_form_schemas | [ [CredentialFormSchema](#credentialformschema) ] | | Yes | | model | [FieldModelSchema](#fieldmodelschema) | | Yes | -#### ModelCredentialValidateResponse +#### ModelFeature + +Enum class for llm feature. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| error | string | | No | -| result | string | | Yes | +| ModelFeature | string | Enum class for llm feature. | | -#### ModelFeature +#### ModelLoadBalancingConfigResponse -Enum class for llm feature. +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | No | +| credentials | object | | Yes | +| enabled | boolean | | Yes | +| id | string | | Yes | +| in_cooldown | boolean | | Yes | +| name | string | | Yes | +| ttl | integer | | Yes | + +#### ModelLoadBalancingResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| ModelFeature | string | Enum class for llm feature. | | +| configs | [ [ModelLoadBalancingConfigResponse](#modelloadbalancingconfigresponse) ] | | Yes | +| enabled | boolean | | Yes | -#### ModelParameterRulesResponse +#### ModelParameterRuleListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -17281,6 +17635,12 @@ Enum class for model property key. | ---- | ---- | ----------- | -------- | | payment_link | string | | Yes | +#### ModelSelectorScope + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ModelSelectorScope | string | | | + #### ModelStatus Enum class for model status. @@ -17315,12 +17675,6 @@ Model with provider entity. | provider | [SimpleProviderEntityResponse](#simpleproviderentityresponse) | | Yes | | status | [ModelStatus](#modelstatus) | | Yes | -#### ModelWithProviderListResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| data | [ [ModelWithProviderEntityResponse](#modelwithproviderentityresponse) ] | | Yes | - #### MoreLikeThisQuery | Name | Type | Description | Required | @@ -17342,6 +17696,12 @@ Model with provider entity. | new_app_id | string | | Yes | | permission_keys | [ string ] | | No | +#### Node + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | | No | + #### NodeIdQuery | Name | Type | Description | Required | @@ -17571,6 +17931,15 @@ Coarse node-level status used by Inspector to pick a banner. | refresh_token | string | | Yes | | token_type | string | | Yes | +#### OAuthSchema + +OAuth schema + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_schema | [ [ProviderConfig](#providerconfig) ] | client schema like client_id, client_secret, etc. | No | +| credentials_schema | [ [ProviderConfig](#providerconfig) ] | credentials schema like access_token, refresh_token, etc. | No | + #### OAuthTokenRequest | Name | Type | Description | Required | @@ -17582,11 +17951,12 @@ Coarse node-level status used by Inspector to pick a banner. | redirect_uri | string | | No | | refresh_token | string | | No | -#### OpaqueObjectResponse +#### Option | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| OpaqueObjectResponse | object | | | +| label | [I18nObject](#i18nobject) | The label of the option | Yes | +| value | string | The value of the option | Yes | #### OutputErrorStrategy @@ -17633,8 +18003,8 @@ output check fails and any configured retry attempts have been exhausted. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| plugin_unique_identifier | string | | Yes | -| version | string | | No | +| manifest | [PluginDeclaration](#plugindeclaration) | | Yes | +| unique_identifier | string | | Yes | #### PaginatedConversationVariableResponse @@ -18030,6 +18400,16 @@ Enum class for parameter type. | node_title | string | | Yes | | pause_type | [HumanInputPauseTypeResponse](#humaninputpausetyperesponse) | | Yes | +#### Permission + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| endpoint | [Endpoint](#endpoint) | | No | +| model | [Model](#model) | | No | +| node | [Node](#node) | | No | +| storage | [Storage](#storage) | | No | +| tool | [Tool](#tool) | | No | + #### PermissionCatalogGroup | Name | Type | Description | Required | @@ -18159,6 +18539,19 @@ Shared permission levels for resources (datasets, credentials, etc.) | upgrade_mode | [UpgradeMode](#upgrademode) | | Yes | | upgrade_time_of_day | integer | | Yes | +#### PluginBundleDependency + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | [core__plugin__entities__bundle__PluginBundleDependency__Type](#core__plugin__entities__bundle__pluginbundledependency__type) | | Yes | +| value | [Github](#github)
[Marketplace](#marketplace)
[Package](#package) | | Yes | + +#### PluginBundleUploadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| PluginBundleUploadResponse | array | | | + #### PluginCategory | Name | Type | Description | Required | @@ -18233,11 +18626,15 @@ Shared permission levels for resources (datasets, credentials, etc.) | has_more | boolean | | Yes | | plugins | [ [PluginCategoryInstalledPluginResponse](#plugincategoryinstalledpluginresponse) ] | | Yes | -#### PluginDaemonOperationResponse +#### PluginDatasourceProviderEntity | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| PluginDaemonOperationResponse | | | | +| declaration | [DatasourceProviderEntityWithPlugin](#datasourceproviderentitywithplugin) | | Yes | +| is_authorized | boolean | | No | +| plugin_id | string | | Yes | +| plugin_unique_identifier | string | | Yes | +| provider | string | | Yes | #### PluginDebuggingKeyResponse @@ -18247,6 +18644,32 @@ Shared permission levels for resources (datasets, credentials, etc.) | key | string | | Yes | | port | integer | | Yes | +#### PluginDeclaration + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_strategy | [AgentStrategyProviderEntity](#agentstrategyproviderentity) | | No | +| author | string | | Yes | +| category | [PluginCategory](#plugincategory) | | Yes | +| created_at | dateTime | | Yes | +| datasource | [DatasourceProviderEntity](#datasourceproviderentity) | | No | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| endpoint | [EndpointProviderDeclaration](#endpointproviderdeclaration) | | No | +| icon | string | | Yes | +| icon_dark | string | | No | +| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| meta | [Meta](#meta) | | Yes | +| model | [ProviderEntity](#providerentity) | | No | +| name | string | | Yes | +| plugins | [Plugins](#plugins) | | Yes | +| repo | string | | No | +| resource | [PluginResourceRequirements](#pluginresourcerequirements) | | Yes | +| tags | [ string ] | | No | +| tool | [ToolProviderEntity](#toolproviderentity) | | No | +| trigger | [TriggerProviderEntity](#triggerproviderentity) | | No | +| verified | boolean | | No | +| version | string | | Yes | + #### PluginDeclarationResponse | Name | Type | Description | Required | @@ -18273,6 +18696,14 @@ Shared permission levels for resources (datasets, credentials, etc.) | verified | boolean | | No | | version | string | | Yes | +#### PluginDecodeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| manifest | [PluginDeclaration](#plugindeclaration) | | Yes | +| unique_identifier | string | The unique identifier of the plugin. | Yes | +| verification | [PluginVerification](#pluginverification) | Basic verification information | No | + #### PluginDependency | Name | Type | Description | Required | @@ -18285,13 +18716,85 @@ Shared permission levels for resources (datasets, credentials, etc.) | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| options | | | Yes | +| options | [ [PluginParameterOption](#pluginparameteroption) ] | | Yes | + +#### PluginEntity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| checksum | string | | Yes | +| created_at | dateTime | | Yes | +| declaration | [PluginDeclaration](#plugindeclaration) | | Yes | +| endpoints_active | integer | | Yes | +| endpoints_setups | integer | | Yes | +| id | string | | Yes | +| installation_id | string | | Yes | +| meta | object | | Yes | +| name | string | | Yes | +| plugin_id | string | | Yes | +| plugin_unique_identifier | string | | Yes | +| runtime_type | string | | Yes | +| source | [PluginInstallationSource](#plugininstallationsource) | | Yes | +| tenant_id | string | | Yes | +| updated_at | dateTime | | Yes | +| version | string | | Yes | + +#### PluginInstallTask + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| completed_plugins | integer | The number of plugins that have been installed. | Yes | +| created_at | dateTime | | Yes | +| id | string | | Yes | +| plugins | [ [PluginInstallTaskPluginStatus](#plugininstalltaskpluginstatus) ] | The status of the plugins. | Yes | +| status | [PluginInstallTaskStatus](#plugininstalltaskstatus) | The status of the install task. | Yes | +| total_plugins | integer | The total number of plugins to be installed. | Yes | +| updated_at | dateTime | | Yes | + +#### PluginInstallTaskPluginStatus + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon | string | The icon of the plugin. | Yes | +| labels | [I18nObject](#i18nobject) | The labels of the plugin. | Yes | +| message | string | The message of the install task. | Yes | +| plugin_id | string | The plugin ID of the install task. | Yes | +| plugin_unique_identifier | string | The plugin unique identifier of the install task. | Yes | +| source | string | The installation source of the plugin | No | +| status | [PluginInstallTaskStatus](#plugininstalltaskstatus) | The status of the install task. | Yes | + +#### PluginInstallTaskStartResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| all_installed | boolean | Whether all plugins are installed. | Yes | +| task | [PluginInstallTask](#plugininstalltask) | The install task. | No | +| task_id | string | The ID of the install task. | Yes | + +#### PluginInstallTaskStatus + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| PluginInstallTaskStatus | string | | | -#### PluginEndpointListResponse +#### PluginInstallation | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| endpoints | [ object ] | Endpoint information | Yes | +| checksum | string | | Yes | +| created_at | dateTime | | Yes | +| declaration | [PluginDeclaration](#plugindeclaration) | | Yes | +| endpoints_active | integer | | Yes | +| endpoints_setups | integer | | Yes | +| id | string | | Yes | +| meta | object | | Yes | +| plugin_id | string | | Yes | +| plugin_unique_identifier | string | | Yes | +| runtime_type | string | | Yes | +| source | [PluginInstallationSource](#plugininstallationsource) | | Yes | +| tenant_id | string | | Yes | +| updated_at | dateTime | | Yes | +| version | string | | Yes | #### PluginInstallationPermissionModel @@ -18316,13 +18819,13 @@ Shared permission levels for resources (datasets, credentials, etc.) | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| plugins | | | Yes | +| plugins | [ [PluginInstallation](#plugininstallation) ] | | Yes | #### PluginListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| plugins | | | Yes | +| plugins | [ [PluginEntity](#pluginentity) ] | | Yes | | total | integer | | Yes | #### PluginManagerModel @@ -18335,7 +18838,7 @@ Shared permission levels for resources (datasets, credentials, etc.) | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| manifest | | | Yes | +| manifest | [PluginDeclaration](#plugindeclaration) | | Yes | #### PluginOAuthAuthorizationUrlResponse @@ -18350,6 +18853,26 @@ Shared permission levels for resources (datasets, credentials, etc.) | message | string | | No | | success | boolean | | Yes | +#### PluginParameterAutoGenerate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | [core__plugin__entities__parameters__PluginParameterAutoGenerate__Type](#core__plugin__entities__parameters__pluginparameterautogenerate__type) | | Yes | + +#### PluginParameterOption + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon | string | The icon of the option, can be a url or a base64 encoded image | No | +| label | [I18nObject](#i18nobject) | The label of the option | Yes | +| value | string | The value of the option | Yes | + +#### PluginParameterTemplate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | Whether the parameter is jinja enabled | No | + #### PluginPermissionResponse | Name | Type | Description | Required | @@ -18370,23 +18893,48 @@ Shared permission levels for resources (datasets, credentials, etc.) | ---- | ---- | ----------- | -------- | | readme | string | | Yes | +#### PluginResourceRequirements + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| memory | integer | | Yes | +| permission | [Permission](#permission) | | No | + #### PluginTaskResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| task | | | Yes | +| task | [PluginInstallTask](#plugininstalltask) | | Yes | #### PluginTasksResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| tasks | | | Yes | +| tasks | [ [PluginInstallTask](#plugininstalltask) ] | | Yes | + +#### PluginVerification + +Verification of the plugin. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| authorized_category | [AuthorizedCategory](#authorizedcategory) | The authorized category of the plugin. | Yes | #### PluginVersionsResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| versions | | | Yes | +| versions | object | | Yes | + +#### Plugins + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasources | [ string ] | | No | +| endpoints | [ string ] | | No | +| models | [ string ] | | No | +| tools | [ string ] | | No | +| triggers | [ string ] | | No | #### PreProcessingRule @@ -18403,6 +18951,17 @@ Shared permission levels for resources (datasets, credentials, etc.) | content | string | | Yes | | summary | string | | No | +#### PriceConfig + +Model class for pricing info. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| currency | string | | Yes | +| input | string | | Yes | +| output | string | | No | +| unit | string | | Yes | + #### PriceConfigResponse Serialized pricing info with codegen-safe decimal string patterns. @@ -18429,11 +18988,37 @@ Dataset Process Rule Mode | ---- | ---- | ----------- | -------- | | ProcessRuleMode | string | Dataset Process Rule Mode | | -#### ProviderCredentialResponse +#### ProcessRuleResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| credentials | object | | No | +| limits | object | | Yes | +| mode | [ProcessRuleMode](#processrulemode) | | Yes | +| rules | [Rule](#rule) | | No | + +#### ProviderConfig + +Model class for common provider settings like credentials + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default | integer
string
number
boolean | | No | +| help | [I18nObject](#i18nobject) | | No | +| label | [I18nObject](#i18nobject) | | No | +| multiple | boolean | | No | +| name | string | The name of the credentials | Yes | +| options | [ [Option](#option) ] | | No | +| placeholder | [I18nObject](#i18nobject) | | No | +| required | boolean | | No | +| scope | [AppSelectorScope](#appselectorscope)
[ModelSelectorScope](#modelselectorscope)
[ToolSelectorScope](#toolselectorscope) | | No | +| type | [core__entities__provider_entities__BasicProviderConfig__Type](#core__entities__provider_entities__basicproviderconfig__type) | The type of the credentials | Yes | +| url | string | | No | + +#### ProviderConfigListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ProviderConfigListResponse | array | | | #### ProviderCredentialSchema @@ -18443,16 +19028,19 @@ Model class for provider credential schema. | ---- | ---- | ----------- | -------- | | credential_form_schemas | [ [CredentialFormSchema](#credentialformschema) ] | | Yes | -#### ProviderCredentialValidateResponse +#### ProviderCredentialsResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| error | string | | No | -| result | string,
**Available values:** "error", "success" | *Enum:* `"error"`, `"success"` | Yes | +| credentials | object | | No | -#### ProviderEntityResponse +#### ProviderEntity -Runtime provider response with codegen-safe model pricing schemas. +Runtime-native provider schema. + +`provider` is the canonical runtime identifier. `provider_name` is a +compatibility alias for callers that still resolve providers by short name and +is empty when no alias exists. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -18464,14 +19052,35 @@ Runtime provider response with codegen-safe model pricing schemas. | icon_small_dark | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | | label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | | model_credential_schema | [ModelCredentialSchema](#modelcredentialschema) | | No | -| models | [ [AIModelEntityResponse](#aimodelentityresponse) ],
**Default:** | | No | +| models | [ [AIModelEntity](#aimodelentity) ] | | No | | position | object | | No | | provider | string | | Yes | | provider_credential_schema | [ProviderCredentialSchema](#providercredentialschema) | | No | | provider_name | string | | No | | supported_model_types | [ [ModelType](#modeltype) ] | | Yes | -#### ProviderHelpEntity +#### ProviderEntityResponse + +Runtime provider response with codegen-safe model pricing schemas. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| background | string | | No | +| configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes | +| description | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| help | [ProviderHelpEntity](#providerhelpentity) | | No | +| icon_small | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| icon_small_dark | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| model_credential_schema | [ModelCredentialSchema](#modelcredentialschema) | | No | +| models | [ [AIModelEntityResponse](#aimodelentityresponse) ],
**Default:** | | No | +| position | object | | No | +| provider | string | | Yes | +| provider_credential_schema | [ProviderCredentialSchema](#providercredentialschema) | | No | +| provider_name | string | | No | +| supported_model_types | [ [ModelType](#modeltype) ] | | Yes | + +#### ProviderHelpEntity Model class for provider help. @@ -18480,6 +19089,12 @@ Model class for provider help. | title | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | | url | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +#### ProviderModelListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ModelWithProviderEntityResponse](#modelwithproviderentityresponse) ] | | Yes | + #### ProviderModelWithStatusEntity Model class for model response. @@ -18537,12 +19152,6 @@ Model class for provider response. | ---- | ---- | ----------- | -------- | | ProviderType | string | | | -#### ProviderWithModelsDataResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| data | [ [ProviderWithModelsResponse](#providerwithmodelsresponse) ] | | Yes | - #### ProviderWithModelsResponse Model class for provider with models response. @@ -18559,11 +19168,17 @@ Model class for provider with models response. #### PublishWorkflowPayload -Payload for publishing snippet workflow. +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| marked_comment | string | | No | +| marked_name | string | | No | + +#### PublishWorkflowResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| knowledge_base_setting | object | | No | +| created_at | integer | | Yes | +| result | string | | Yes | #### PublishedWorkflowRunPayload @@ -18640,6 +19255,27 @@ Model class for provider quota configuration. | ---- | ---- | ----------- | -------- | | yaml_content | string | | Yes | +#### RagPipelineEnvironmentVariableListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [RagPipelineEnvironmentVariableResponse](#ragpipelineenvironmentvariableresponse) ] | | Yes | + +#### RagPipelineEnvironmentVariableResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | Yes | +| editable | boolean | | Yes | +| edited | boolean | | Yes | +| id | string | | Yes | +| name | string | | Yes | +| selector | [ string ] | | Yes | +| type | string | | Yes | +| value | | | Yes | +| value_type | string | | Yes | +| visible | boolean | | Yes | + #### RagPipelineImportCheckDependenciesResponse | Name | Type | Description | Required | @@ -18672,19 +19308,28 @@ Model class for provider quota configuration. | pipeline_id | string | | No | | status | [ImportStatus](#importstatus) | | Yes | -#### RagPipelineOpaqueResponse +#### RagPipelineRecommendedPluginQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| RagPipelineOpaqueResponse | | | | +| type | string,
**Default:** all | | No | -#### RagPipelineRecommendedPluginQuery +#### RagPipelineRecommendedPluginResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| type | string,
**Default:** all | | No | +| installed_recommended_plugins | [ object ] | Installed tool provider payloads. Shape follows the tool provider serializer. | Yes | +| uninstalled_recommended_plugins | [ object ] | Marketplace plugin manifest payloads returned by the marketplace service. | Yes | -#### RagPipelineStepParametersResponse +#### RagPipelineTransformResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| dataset_id | string | | Yes | +| pipeline_id | string | | Yes | +| status | string | | Yes | + +#### RagPipelineVariablesResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -18801,6 +19446,16 @@ Model class for provider quota configuration. | ---- | ---- | ----------- | -------- | | access_policies | [ [AccessPolicy](#accesspolicy) ] | | No | +#### RequestLog + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | dateTime | The created at of the request log | Yes | +| endpoint | string | The endpoint of the request log | Yes | +| id | string | The id of the request log | Yes | +| request | object | The request of the request log | Yes | +| response | object | The response of the request log | Yes | + #### RerankingModel | Name | Type | Description | Required | @@ -19026,11 +19681,19 @@ Model class for provider quota configuration. | last_id | string | | No | | limit | integer,
**Default:** 20 | | No | +#### SchemaDefinitionItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| label | string | | Yes | +| name | string | | Yes | +| schema | object | | Yes | + #### SchemaDefinitionsResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| SchemaDefinitionsResponse | | | | +| SchemaDefinitionsResponse | array | | | #### SegmentAttachmentResponse @@ -19151,6 +19814,14 @@ Model class for provider quota configuration. | id | string | | Yes | | name | string | | Yes | +#### SimpleAccountResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | + #### SimpleConversation | Name | Type | Description | Required | @@ -19203,7 +19874,7 @@ Model class for provider quota configuration. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| model_dict | [JSONValue](#jsonvalue) | | No | +| model | [JSONValue](#jsonvalue) | | No | | pre_prompt | string | | No | #### SimpleProviderEntityResponse @@ -19288,32 +19959,6 @@ Validated metadata extracted from a Skill package. | inferable | boolean | | Yes | | reason | string | | No | -#### Snippet - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| created_at | long | | No | -| created_by | [_AnonymousInlineModel_b0fd3f86d9d5](#_anonymousinlinemodel_b0fd3f86d9d5) | | No | -| description | string | | No | -| graph | object | | No | -| icon_info | object | | No | -| id | string | | No | -| input_fields | object | | No | -| is_published | boolean | | No | -| name | string | | No | -| tags | [ [_AnonymousInlineModel_7b8b49ca164e](#_anonymousinlinemodel_7b8b49ca164e) ] | | No | -| type | string | | No | -| updated_at | long | | No | -| updated_by | [_AnonymousInlineModel_b0fd3f86d9d5](#_anonymousinlinemodel_b0fd3f86d9d5) | | No | -| use_count | integer | | No | -| version | integer | | No | - -#### SnippetDependencyCheckResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| SnippetDependencyCheckResponse | object | | | - #### SnippetDraftConfigResponse | Name | Type | Description | Required | @@ -19350,6 +19995,17 @@ Payload for syncing snippet draft workflow. | hash | string | | No | | input_fields | [ object ] | | No | +#### SnippetImportInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| current_dsl_version | string,
**Default:** 0.1.0 | | No | +| error | string | | No | +| id | string | | Yes | +| imported_dsl_version | string | | No | +| snippet_id | string | | No | +| status | [ImportStatus](#importstatus) | | Yes | + #### SnippetImportPayload Payload for importing snippet from DSL. @@ -19363,12 +20019,6 @@ Payload for importing snippet from DSL. | yaml_content | string | YAML content (required for yaml-content mode) | No | | yaml_url | string | YAML URL (required for yaml-url mode) | No | -#### SnippetImportResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| SnippetImportResponse | object | | | - #### SnippetIterationNodeRunPayload Payload for running an iteration node in snippet draft workflow. @@ -19377,24 +20027,24 @@ Payload for running an iteration node in snippet draft workflow. | ---- | ---- | ----------- | -------- | | inputs | object | | No | -#### SnippetList +#### SnippetListItemResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | author_name | string | | No | -| created_at | long | | No | +| created_at | integer | | No | | created_by | string | | No | | description | string | | No | | icon_info | object | | No | -| id | string | | No | -| is_published | boolean | | No | -| name | string | | No | -| tags | [ [_AnonymousInlineModel_7b8b49ca164e](#_anonymousinlinemodel_7b8b49ca164e) ] | | No | -| type | string | | No | -| updated_at | long | | No | +| id | string | | Yes | +| is_published | boolean | | Yes | +| name | string | | Yes | +| tags | [ [SnippetTagResponse](#snippettagresponse) ] | | No | +| type | string | | Yes | +| updated_at | integer | | No | | updated_by | string | | No | -| use_count | integer | | No | -| version | integer | | No | +| use_count | integer | | Yes | +| version | integer | | Yes | #### SnippetListQuery @@ -19417,17 +20067,45 @@ Payload for running a loop node in snippet draft workflow. | ---- | ---- | ----------- | -------- | | inputs | object | | No | -#### SnippetPagination +#### SnippetPaginationResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [_AnonymousInlineModel_744ff9cc03e6](#_anonymousinlinemodel_744ff9cc03e6) ] | | No | -| has_more | boolean | | No | -| limit | integer | | No | -| page | integer | | No | -| total | integer | | No | +| data | [ [SnippetListItemResponse](#snippetlistitemresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | -#### SnippetUseCountResponse +#### SnippetResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by | [SimpleAccountResponse](#simpleaccountresponse) | | No | +| description | string | | No | +| graph | object | | No | +| icon_info | object | | No | +| id | string | | Yes | +| input_fields | [ object ] | | No | +| is_published | boolean | | Yes | +| name | string | | Yes | +| tags | [ [SnippetTagResponse](#snippettagresponse) ] | | No | +| type | string | | Yes | +| updated_at | integer | | No | +| updated_by | [SimpleAccountResponse](#simpleaccountresponse) | | No | +| use_count | integer | | Yes | +| version | integer | | Yes | + +#### SnippetTagResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| name | string | | Yes | +| type | string | | Yes | + +#### SnippetUseCountIncrementResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -19458,7 +20136,7 @@ Query parameters for listing snippet published workflows. | ---- | ---- | ----------- | -------- | | conversation_variables | [ [WorkflowConversationVariableResponse](#workflowconversationvariableresponse) ] | | Yes | | created_at | integer | | Yes | -| created_by | [SimpleAccount](#simpleaccount) | | No | +| created_by | [SimpleAccountResponse](#simpleaccountresponse) | | No | | environment_variables | [ [WorkflowEnvironmentVariableResponse](#workflowenvironmentvariableresponse) ] | | Yes | | features | object | | Yes | | graph | object | | Yes | @@ -19470,7 +20148,7 @@ Query parameters for listing snippet published workflows. | rag_pipeline_variables | [ [PipelineVariableResponse](#pipelinevariableresponse) ] | | Yes | | tool_published | boolean | | Yes | | updated_at | integer | | Yes | -| updated_by | [SimpleAccount](#simpleaccount) | | No | +| updated_by | [SimpleAccountResponse](#simpleaccountresponse) | | No | | version | string | | Yes | #### StarredAppListQuery @@ -19502,6 +20180,13 @@ Query parameters for listing snippet published workflows. | paused | integer | | Yes | | success | integer | | Yes | +#### Storage + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | | No | +| size | integer,
**Default:** 1048576 | | No | + #### StrategySetting | Name | Type | Description | Required | @@ -19526,6 +20211,29 @@ Default configuration for form inputs. | type | [ValueSourceType](#valuesourcetype) | | Yes | | value | string | | No | +#### SubscriptionBuilderApiEntity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_type | [CredentialType](#credentialtype) | The credential type of the subscription builder | Yes | +| credentials | object | The credentials of the subscription builder | Yes | +| endpoint | string | The endpoint id of the subscription builder | Yes | +| id | string | The id of the subscription builder | Yes | +| name | string | The name of the subscription builder | Yes | +| parameters | object | The parameters of the subscription builder | Yes | +| properties | object | The properties of the subscription builder | Yes | +| provider | string | The provider id of the subscription builder | Yes | + +#### SubscriptionConstructor + +The subscription constructor of the trigger provider + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials_schema | [ [ProviderConfig](#providerconfig) ] | The credentials schema of the subscription constructor | No | +| oauth_schema | [OAuthSchema](#oauthschema) | The OAuth schema of the subscription constructor if OAuth is supported | No | +| parameters | [ [EventParameter](#eventparameter) ] | The parameters schema of the subscription constructor | No | + #### SubscriptionModel | Name | Type | Description | Required | @@ -19552,6 +20260,28 @@ Default configuration for form inputs. | ---- | ---- | ----------- | -------- | | data | [ string ] | | Yes | +#### SummaryEntryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| error | string | | No | +| segment_id | string | | Yes | +| segment_position | integer | | Yes | +| status | string | | Yes | +| summary_preview | string | | No | +| updated_at | integer | | No | + +#### SummaryStatusResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| completed | integer | | No | +| error | integer | | No | +| generating | integer | | No | +| not_started | integer | | No | +| timeout | integer | | No | + #### SwitchWorkspacePayload | Name | Type | Description | Required | @@ -19579,9 +20309,9 @@ Default configuration for form inputs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| hash | string | | No | -| result | string | | No | -| updated_at | string | | No | +| hash | string | | Yes | +| result | string | | Yes | +| updated_at | integer | | Yes | #### SystemConfigurationResponse @@ -19669,6 +20399,12 @@ Model class for provider system configuration response. | keyword | string | Search keyword | No | | type | string,
**Available values:** "", "app", "knowledge", "snippet" | Tag type filter
*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No | +#### TagListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| TagListResponse | array | | | + #### TagResponse | Name | Type | Description | Required | @@ -19722,6 +20458,7 @@ Tag type | created_at | integer | | No | | current | boolean | | Yes | | id | string | | Yes | +| last_opened_at | integer | | No | | name | string | | No | | plan | string | | No | | status | string | | No | @@ -19773,9 +20510,11 @@ Tag type #### TextToSpeechVoiceListResponse +Available voices + | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| TextToSpeechVoiceListResponse | array | | | +| TextToSpeechVoiceListResponse | array | Available voices | | #### TextToSpeechVoiceQuery @@ -19783,6 +20522,13 @@ Tag type | ---- | ---- | ----------- | -------- | | language | string | Language code | Yes | +#### TextToSpeechVoiceResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | Voice display name | Yes | +| value | string | Voice identifier | Yes | + #### TokensPerSecondStatisticItem | Name | Type | Description | Required | @@ -19796,11 +20542,58 @@ Tag type | ---- | ---- | ----------- | -------- | | data | [ [TokensPerSecondStatisticItem](#tokenspersecondstatisticitem) ] | | Yes | -#### ToolOAuthClientSchemaResponse +#### Tool | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| ToolOAuthClientSchemaResponse | array | | | +| enabled | boolean | | No | + +#### ToolApiEntity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | | Yes | +| description | [I18nObject](#i18nobject) | | Yes | +| label | [I18nObject](#i18nobject) | | Yes | +| labels | [ string ] | | No | +| name | string | | Yes | +| output_schema | object | | No | +| parameters | [ [ToolParameter](#toolparameter) ] | | No | + +#### ToolApiListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolApiListResponse | array | | | + +#### ToolEmojiIcon + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| background | string | | Yes | +| content | string | | Yes | + +#### ToolLabel + +Tool label + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon | string | The icon of the tool | Yes | +| label | [I18nObject](#i18nobject) | The label of the tool | Yes | +| name | string | The name of the tool | Yes | + +#### ToolLabelEnum + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolLabelEnum | string | | | + +#### ToolLabelListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolLabelListResponse | array | | | #### ToolOAuthCustomClientPayload @@ -19809,11 +20602,29 @@ Tag type | client_params | object | | No | | enable_oauth_custom_client | boolean | | No | -#### ToolOAuthCustomClientResponse +#### ToolParameter + +Overrides type | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| ToolOAuthCustomClientResponse | object | | | +| auto_generate | [PluginParameterAutoGenerate](#pluginparameterautogenerate) | | No | +| default | number
integer
string
boolean
[ object ]
object | | No | +| form | [ToolParameterForm](#toolparameterform) | The form of the parameter, schema/form/llm | Yes | +| human_description | [I18nObject](#i18nobject) | The description presented to the user | No | +| input_schema | object | | No | +| label | [I18nObject](#i18nobject) | The label presented to the user | Yes | +| llm_description | string | | No | +| max | number
integer | | No | +| min | number
integer | | No | +| name | string | The name of the parameter | Yes | +| options | [ [PluginParameterOption](#pluginparameteroption) ] | | No | +| placeholder | [I18nObject](#i18nobject) | The placeholder presented to the user | No | +| precision | integer | | No | +| required | boolean | | No | +| scope | string | | No | +| template | [PluginParameterTemplate](#pluginparametertemplate) | | No | +| type | [ToolParameterType](#toolparametertype) | The type of the parameter | Yes | #### ToolParameterForm @@ -19821,17 +20632,105 @@ Tag type | ---- | ---- | ----------- | -------- | | ToolParameterForm | string | | | +#### ToolParameterType + +removes TOOLS_SELECTOR from PluginParameterType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolParameterType | string | removes TOOLS_SELECTOR from PluginParameterType | | + +#### ToolProviderApiEntityResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allow_delete | boolean,
**Default:** true | | No | +| authentication | [MCPAuthentication](#mcpauthentication) | The OAuth config of the MCP tool | No | +| author | string | | Yes | +| configuration | [MCPConfiguration](#mcpconfiguration) | The timeout and sse_read_timeout of the MCP tool | No | +| description | [I18nObject](#i18nobject) | | Yes | +| icon | string
object | | Yes | +| icon_dark | string
object | | No | +| id | string | | Yes | +| identity_mode | string,
**Default:** off | Identity-forwarding mechanism: 'off' or 'idp_token' | No | +| is_dynamic_registration | boolean,
**Default:** true | Whether the MCP tool is dynamically registered | No | +| is_team_authorization | boolean | | No | +| label | [I18nObject](#i18nobject) | | Yes | +| labels | [ string ] | | No | +| masked_headers | object | The masked headers of the MCP tool | No | +| name | string | | Yes | +| original_headers | object | The original headers of the MCP tool | No | +| plugin_id | string | The plugin id of the tool | No | +| plugin_unique_identifier | string | The unique identifier of the tool | No | +| server_identifier | string | The server identifier of the MCP tool | No | +| server_url | string | The server url of the tool | No | +| team_credentials | object | | No | +| tools | [ [ToolApiEntity](#toolapientity) ] | | No | +| type | [ToolProviderType](#toolprovidertype) | | Yes | +| updated_at | integer | | No | +| workflow_app_id | string | The app id of the workflow tool | No | + +#### ToolProviderCredentialApiEntity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_by | string | User ID of the credential creator | No | +| credential_type | [CredentialType](#credentialtype) | The type of the credential | Yes | +| credentials | object | The credentials of the provider | No | +| from_other_member | boolean | True when this credential is being returned only because a workflow/agent node still references it but it would normally be hidden from this user by the visibility filter (another member's only_me credential). The frontend renders it as 'borrowed' — selectable until the node switches away, but not editable/deletable. | No | +| id | string | The unique id of the credential | Yes | +| is_default | boolean | Whether the credential is the default credential for the provider in the workspace | No | +| name | string | The name of the credential | Yes | +| partial_member_list | [ string ] | List of user IDs allowed when visibility is partial_members | No | +| provider | string | The provider of the credential | Yes | +| visibility | string,
**Default:** all_team_members | Credential visibility: only_me, all_team_members, or partial_members | No | + +#### ToolProviderCredentialInfoApiEntity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | [ [ToolProviderCredentialApiEntity](#toolprovidercredentialapientity) ] | The credentials of the provider | Yes | +| is_oauth_custom_client_enabled | boolean | Whether the OAuth custom client is enabled for the provider | No | +| supported_credential_types | [ [CredentialType](#credentialtype) ] | The supported credential types of the provider | Yes | + +#### ToolProviderCredentialListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolProviderCredentialListResponse | array | | | + +#### ToolProviderEntity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials_schema | [ [ProviderConfig](#providerconfig) ] | | No | +| identity | [ToolProviderIdentity](#toolprovideridentity) | | Yes | +| oauth_schema | [OAuthSchema](#oauthschema) | | No | +| plugin_id | string | | No | + +#### ToolProviderIdentity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | The author of the tool | Yes | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The description of the tool | Yes | +| icon | string | The icon of the tool | Yes | +| icon_dark | string | The dark icon of the tool | No | +| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The label of the tool | Yes | +| name | string | The name of the tool | Yes | +| tags | [ [ToolLabelEnum](#toollabelenum) ] | The tags of the tool | No | + #### ToolProviderListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | type | string | | No | -#### ToolProviderOpaqueResponse +#### ToolProviderListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| ToolProviderOpaqueResponse | | | | +| ToolProviderListResponse | array | | | #### ToolProviderType @@ -19841,6 +20740,12 @@ Enum class for tool provider | ---- | ---- | ----------- | -------- | | ToolProviderType | string | Enum class for tool provider | | +#### ToolSelectorScope + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolSelectorScope | string | | | + #### TraceAppConfigResponse | Name | Type | Description | Required | @@ -19869,252 +20774,211 @@ Enum class for tool provider | ---- | ---- | ----------- | -------- | | tracing_provider | string | Tracing provider name | Yes | -#### TrialAppDetailWithSite +#### TrialDatasetListItemResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| access_mode | string | | No | -| api_base_url | string | | No | -| created_at | long | | No | -| created_by | string | | No | -| deleted_tools | [ [TrialDeletedTool](#trialdeletedtool) ] | | No | -| description | string | | No | -| enable_api | boolean | | No | -| enable_site | boolean | | No | -| icon | string | | No | -| icon_background | string | | No | -| icon_type | string | | No | -| icon_url | string | | No | -| id | string | | No | -| max_active_requests | integer | | No | -| mode | string | | No | -| model_config | [TrialAppModelConfig](#trialappmodelconfig) | | No | -| name | string | | No | +| app_count | integer | | Yes | +| author_name | string | | Yes | +| built_in_field_enabled | boolean | | Yes | +| chunk_structure | string | | Yes | +| created_at | integer | | Yes | +| created_by | string | | Yes | +| data_source_type | string | | Yes | +| description | string | | Yes | +| doc_form | string | | Yes | +| doc_metadata | [ [DatasetDocMetadataResponse](#datasetdocmetadataresponse) ] | | Yes | +| document_count | integer | | Yes | +| embedding_available | boolean | | No | +| embedding_model | string | | Yes | +| embedding_model_provider | string | | Yes | +| enable_api | boolean | | Yes | +| external_knowledge_info | [DatasetExternalKnowledgeInfoResponse](#datasetexternalknowledgeinforesponse) | | No | +| external_retrieval_model | [DatasetExternalRetrievalModelResponse](#datasetexternalretrievalmodelresponse) | | Yes | +| icon_info | [DatasetIconInfoResponse](#dataseticoninforesponse) | | No | +| id | string | | Yes | +| indexing_technique | string | | Yes | +| is_multimodal | boolean | | Yes | +| is_published | boolean | | Yes | +| maintainer | string | | No | +| name | string | | Yes | +| permission | string | | Yes | | permission_keys | [ string ] | | No | -| site | [TrialSite](#trialsite) | | No | -| tags | [ [TrialTag](#trialtag) ] | | No | -| updated_at | long | | No | -| updated_by | string | | No | -| use_icon_as_answer_icon | boolean | | No | -| workflow | [TrialWorkflowPartial](#trialworkflowpartial) | | No | +| pipeline_id | string | | Yes | +| provider | string | | Yes | +| retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | | Yes | +| runtime_mode | string | | Yes | +| summary_index_setting | [DatasetSummaryIndexSettingResponse](#datasetsummaryindexsettingresponse) | | No | +| tags | [ [DatasetTagResponse](#datasettagresponse) ] | | Yes | +| total_available_documents | integer | | Yes | +| total_documents | integer | | Yes | +| updated_at | integer | | Yes | +| updated_by | string | | Yes | +| word_count | integer | | Yes | -#### TrialAppModelConfig +#### TrialDatasetListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| agent_mode | object | | No | -| annotation_reply | object | | No | -| chat_prompt_config | object | | No | -| completion_prompt_config | object | | No | -| created_at | long | | No | -| created_by | string | | No | -| dataset_configs | object | | No | -| dataset_query_variable | string | | No | -| external_data_tools | [ object ] | | No | -| file_upload | object | | No | -| model | object | | No | -| more_like_this | object | | No | -| opening_statement | string | | No | -| pre_prompt | string | | No | -| prompt_type | string | | No | -| retriever_resource | object | | No | -| sensitive_word_avoidance | object | | No | -| speech_to_text | object | | No | -| suggested_questions | [ string ] | | No | -| suggested_questions_after_answer | object | | No | -| text_to_speech | object | | No | -| updated_at | long | | No | -| updated_by | string | | No | -| user_input_form | [ object ] | | No | +| ids | [ string ] | Dataset IDs | No | +| limit | integer,
**Default:** 20 | Number of items per page | No | +| page | integer,
**Default:** 1 | Page number | No | + +#### TrialDatasetListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [TrialDatasetListItemResponse](#trialdatasetlistitemresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | -#### TrialConversationVariable +#### TrialModelsResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| description | string | | No | -| id | string | | No | -| name | string | | No | -| value | string
integer
number
boolean
object
[ object ] | | No | -| value_type | string | | No | +| trial_models | [ string ] | | Yes | -#### TrialDataset +#### TriggerCreationMethod | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | long | | No | -| created_by | string | | No | -| data_source_type | string | | No | -| description | string | | No | -| id | string | | No | -| indexing_technique | string | | No | -| name | string | | No | -| permission | string | | No | -| permission_keys | [ string ] | | No | +| TriggerCreationMethod | string | | | -#### TrialDatasetList +#### TriggerDebugErrorResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [TrialDataset](#trialdataset) ] | | No | -| has_more | boolean | | No | -| limit | integer | | No | -| page | integer | | No | -| total | integer | | No | +| error | string | | No | +| status | string | | Yes | -#### TrialDatasetListQuery +#### TriggerDebugWaitingResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| ids | [ string ] | Dataset IDs | No | -| limit | integer,
**Default:** 20 | Number of items per page | No | -| page | integer,
**Default:** 1 | Page number | No | +| retry_in | integer | | Yes | +| status | string | | Yes | -#### TrialDeletedTool +#### TriggerOAuthAuthorizeResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| provider_id | string | | No | -| tool_name | string | | No | -| type | string | | No | +| authorization_url | string | | Yes | +| subscription_builder | [SubscriptionBuilderApiEntity](#subscriptionbuilderapientity) | | Yes | +| subscription_builder_id | string | | Yes | -#### TrialModelsResponse +#### TriggerOAuthClientPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| trial_models | [ string ] | | Yes | +| client_params | object | | No | +| enabled | boolean | | No | -#### TrialPipelineVariable +#### TriggerOAuthClientResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| allow_file_extension | [ string ] | | No | -| allow_file_upload_methods | [ string ] | | No | -| allowed_file_types | [ string ] | | No | -| belong_to_node_id | string | | No | -| default_value | string
integer
number
boolean
object
[ object ] | | No | -| label | string | | No | -| max_length | integer | | No | -| options | [ string ] | | No | -| placeholder | string | | No | -| required | boolean | | No | -| tooltips | string | | No | -| type | string | | No | -| unit | string | | No | -| variable | string | | No | +| configured | boolean | | Yes | +| custom_configured | boolean | | Yes | +| custom_enabled | boolean | | Yes | +| oauth_client_schema | [ [ProviderConfig](#providerconfig) ] | | Yes | +| params | object | | No | +| redirect_uri | string | | Yes | +| system_configured | boolean | | Yes | -#### TrialSimpleAccount +#### TriggerProviderApiEntity | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| email | string | | No | -| id | string | | No | -| name | string | | No | +| author | string | The author of the trigger provider | Yes | +| description | [I18nObject](#i18nobject) | The description of the trigger provider | Yes | +| events | [ [EventApiEntity](#eventapientity) ] | The events of the trigger provider | Yes | +| icon | string | The icon of the trigger provider | No | +| icon_dark | string | The dark icon of the trigger provider | No | +| label | [I18nObject](#i18nobject) | The label of the trigger provider | Yes | +| name | string | The name of the trigger provider | Yes | +| plugin_id | string | The plugin id of the tool | No | +| plugin_unique_identifier | string | The unique identifier of the tool | No | +| subscription_constructor | [SubscriptionConstructor](#subscriptionconstructor) | The subscription constructor of the trigger provider | No | +| subscription_schema | [ [ProviderConfig](#providerconfig) ] | The subscription schema of the trigger provider | No | +| supported_creation_methods | [ [TriggerCreationMethod](#triggercreationmethod) ] | Supported creation methods for the trigger provider. like 'OAUTH', 'APIKEY', 'MANUAL'. | No | +| tags | [ string ] | The tags of the trigger provider | No | -#### TrialSite +#### TriggerProviderEntity + +The configuration of a trigger provider | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| access_token | string | | No | -| app_base_url | string | | No | -| chat_color_theme | string | | No | -| chat_color_theme_inverted | boolean | | No | -| code | string | | No | -| copyright | string | | No | -| created_at | long | | No | -| created_by | string | | No | -| custom_disclaimer | string | | No | -| customize_domain | string | | No | -| customize_token_strategy | string | | No | -| default_language | string | | No | -| description | string | | No | -| icon | string | | No | -| icon_background | string | | No | -| icon_type | string | | No | -| icon_url | string | | No | -| privacy_policy | string | | No | -| prompt_public | boolean | | No | -| show_workflow_steps | boolean | | No | -| title | string | | No | -| updated_at | long | | No | -| updated_by | string | | No | -| use_icon_as_answer_icon | boolean | | No | +| events | [ [EventEntity](#evententity) ] | The events of the trigger provider | No | +| identity | [TriggerProviderIdentity](#triggerprovideridentity) | | Yes | +| subscription_constructor | [SubscriptionConstructor](#subscriptionconstructor) | The subscription constructor of the trigger provider | No | +| subscription_schema | [ [ProviderConfig](#providerconfig) ] | The configuration schema stored in the subscription entity | No | -#### TrialTag +#### TriggerProviderErrorResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| id | string | | No | -| name | string | | No | -| type | string | | No | +| error | string | | Yes | + +#### TriggerProviderIdentity -#### TrialWorkflow +The identity of the trigger provider | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| conversation_variables | [ [TrialConversationVariable](#trialconversationvariable) ] | | No | -| created_at | long | | No | -| created_by | [TrialSimpleAccount](#trialsimpleaccount) | | No | -| environment_variables | [ object ] | | No | -| features | object | | No | -| graph | object | | No | -| hash | string | | No | -| id | string | | No | -| marked_comment | string | | No | -| marked_name | string | | No | -| rag_pipeline_variables | [ [TrialPipelineVariable](#trialpipelinevariable) ] | | No | -| tool_published | boolean | | No | -| updated_at | long | | No | -| updated_by | [TrialSimpleAccount](#trialsimpleaccount) | | No | -| version | string | | No | +| author | string | The author of the trigger provider | Yes | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The description of the trigger provider | Yes | +| icon | string | The icon of the trigger provider | No | +| icon_dark | string | The dark icon of the trigger provider | No | +| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The label of the trigger provider | Yes | +| name | string | The name of the trigger provider | Yes | +| tags | [ string ] | The tags of the trigger provider | No | -#### TrialWorkflowPartial +#### TriggerProviderListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | long | | No | -| created_by | string | | No | -| id | string | | No | -| updated_at | long | | No | -| updated_by | string | | No | +| TriggerProviderListResponse | array | | | -#### TriggerOAuthAuthorizeResponse +#### TriggerProviderSubscriptionApiEntity | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| authorization_url | string | | Yes | -| subscription_builder | | | Yes | -| subscription_builder_id | string | | Yes | +| credential_type | [CredentialType](#credentialtype) | The type of the credential | Yes | +| credentials | object | The credentials of the subscription | Yes | +| endpoint | string | The endpoint of the subscription | Yes | +| id | string | The unique id of the subscription | Yes | +| name | string | The name of the subscription | Yes | +| parameters | object | The parameters of the subscription | Yes | +| properties | object | The properties of the subscription | Yes | +| provider | string | The provider id of the subscription | Yes | +| workflows_in_use | integer | The number of workflows using this subscription | Yes | -#### TriggerOAuthClientPayload +#### TriggerProviderSubscriptionListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| client_params | object | | No | -| enabled | boolean | | No | +| TriggerProviderSubscriptionListResponse | array | | | -#### TriggerOAuthClientResponse +#### TriggerSubscriptionBuilderCreatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| configured | boolean | | Yes | -| custom_configured | boolean | | Yes | -| custom_enabled | boolean | | Yes | -| oauth_client_schema | | | Yes | -| params | object | | Yes | -| redirect_uri | string | | Yes | -| system_configured | boolean | | Yes | +| credential_type | string,
**Default:** unauthorized | | No | -#### TriggerProviderOpaqueResponse +#### TriggerSubscriptionBuilderCreateResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| TriggerProviderOpaqueResponse | | | | +| subscription_builder | [SubscriptionBuilderApiEntity](#subscriptionbuilderapientity) | | Yes | -#### TriggerSubscriptionBuilderCreatePayload +#### TriggerSubscriptionBuilderLogsResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| credential_type | string,
**Default:** unauthorized | | No | +| logs | [ [RequestLog](#requestlog) ] | | Yes | #### TriggerSubscriptionBuilderUpdatePayload @@ -20131,6 +20995,12 @@ Enum class for tool provider | ---- | ---- | ----------- | -------- | | credentials | object | | Yes | +#### TriggerVerificationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| verified | boolean | | Yes | + #### Type | Name | Type | Description | Required | @@ -20187,11 +21057,11 @@ Payload for updating a snippet. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| attachment_image_file_size_limit | integer | | No | +| attachment_image_file_size_limit | integer | | Yes | | audio_file_size_limit | integer | | Yes | | batch_count_limit | integer | | Yes | | file_size_limit | integer | | Yes | -| file_upload_limit | integer | | No | +| file_upload_limit | integer | | Yes | | image_file_batch_limit | integer | | Yes | | image_file_size_limit | integer | | Yes | | single_chunk_attachment_limit | integer | | Yes | @@ -20246,6 +21116,13 @@ User action configuration. | ---- | ---- | ----------- | -------- | | data | [ [UserSatisfactionRateStatisticItem](#usersatisfactionratestatisticitem) ] | | Yes | +#### ValidationResultResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| error | string | | No | +| result | string,
**Available values:** "error", "success" | *Enum:* `"error"`, `"success"` | Yes | + #### ValueSourceType ValueSourceType records whether the value comes from a static setting @@ -20390,7 +21267,7 @@ How a workflow node is bound to an Agent. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | created_at | integer | | No | -| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No | | created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | | created_by_role | string | | No | | created_from | string | | No | @@ -20427,7 +21304,7 @@ How a workflow node is bound to an Agent. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | created_at | integer | | No | -| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No | | created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | | id | string | | Yes | | trigger_metadata | | | No | @@ -20528,7 +21405,7 @@ How a workflow node is bound to an Agent. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| users | [ [AccountWithRole](#accountwithrole) ] | | Yes | +| users | [ [AccountWithRoleResponse](#accountwithroleresponse) ] | | Yes | #### WorkflowCommentReply @@ -20643,46 +21520,35 @@ How a workflow node is bound to an Agent. | ---- | ---- | ----------- | -------- | | data | [ [WorkflowDailyTokenCostStatisticItem](#workflowdailytokencoststatisticitem) ] | | Yes | -#### WorkflowDraftEnvVariable - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| description | string | | No | -| edited | boolean | | No | -| id | string | | No | -| name | string | | No | -| selector | [ string ] | | No | -| type | string | | No | -| value_type | string | | No | -| visible | boolean | | No | - -#### WorkflowDraftEnvVariableList +#### WorkflowDraftEnvironmentVariableListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| items | [ [WorkflowDraftEnvVariable](#workflowdraftenvvariable) ] | | No | +| items | [ [WorkflowDraftEnvironmentVariableResponse](#workflowdraftenvironmentvariableresponse) ] | | Yes | -#### WorkflowDraftVariable +#### WorkflowDraftEnvironmentVariableResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | description | string | | No | -| edited | boolean | | No | -| full_content | object | | No | -| id | string | | No | -| is_truncated | boolean | | No | -| name | string | | No | -| selector | [ string ] | | No | -| type | string | | No | -| value | string
integer
number
boolean
object
[ object ] | | No | -| value_type | string | | No | -| visible | boolean | | No | +| editable | boolean | | Yes | +| edited | boolean | | Yes | +| id | string | | Yes | +| name | string | | Yes | +| selector | [ string ] | | Yes | +| type | string | | Yes | +| value | | | Yes | +| value_type | string | | Yes | +| visible | boolean | | Yes | -#### WorkflowDraftVariableList +#### WorkflowDraftVariableFullContentResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| items | [ [WorkflowDraftVariable](#workflowdraftvariable) ] | | No | +| download_url | string | | Yes | +| length | integer | | Yes | +| size_bytes | integer | | Yes | +| value_type | string | | Yes | #### WorkflowDraftVariableListQuery @@ -20691,20 +21557,42 @@ How a workflow node is bound to an Agent. | limit | integer,
**Default:** 20 | Items per page | No | | page | integer,
**Default:** 1 | Page number | No | -#### WorkflowDraftVariableListWithoutValue +#### WorkflowDraftVariableListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [WorkflowDraftVariableResponse](#workflowdraftvariableresponse) ] | | Yes | + +#### WorkflowDraftVariableListWithoutValueResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| items | [ [WorkflowDraftVariableWithoutValue](#workflowdraftvariablewithoutvalue) ] | | No | -| total | integer | | No | +| items | [ [WorkflowDraftVariableWithoutValueResponse](#workflowdraftvariablewithoutvalueresponse) ] | | Yes | +| total | integer | | Yes | #### WorkflowDraftVariablePatchPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| name | string | | No | +| name | string | Variable name | No | | value | | | No | +#### WorkflowDraftVariableResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | Yes | +| edited | boolean | | Yes | +| full_content | [WorkflowDraftVariableFullContentResponse](#workflowdraftvariablefullcontentresponse) | | Yes | +| id | string | | Yes | +| is_truncated | boolean | | Yes | +| name | string | | Yes | +| selector | [ string ] | | Yes | +| type | string | | Yes | +| value | | | Yes | +| value_type | string | | Yes | +| visible | boolean | | Yes | + #### WorkflowDraftVariableUpdatePayload | Name | Type | Description | Required | @@ -20712,19 +21600,19 @@ How a workflow node is bound to an Agent. | name | string | Variable name | No | | value | | Variable value | No | -#### WorkflowDraftVariableWithoutValue +#### WorkflowDraftVariableWithoutValueResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| description | string | | No | -| edited | boolean | | No | -| id | string | | No | -| is_truncated | boolean | | No | -| name | string | | No | -| selector | [ string ] | | No | -| type | string | | No | -| value_type | string | | No | -| visible | boolean | | No | +| description | string | | Yes | +| edited | boolean | | Yes | +| id | string | | Yes | +| is_truncated | boolean | | Yes | +| name | string | | Yes | +| selector | [ string ] | | Yes | +| type | string | | Yes | +| value_type | string | | Yes | +| visible | boolean | | Yes | #### WorkflowEnvironmentVariableResponse @@ -20864,20 +21752,13 @@ can reuse its existing handler. | variable | string | | No | | variable_selector | [ string
integer
number
boolean ] | | No | -#### WorkflowPublishResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| created_at | integer | | Yes | -| result | string | | Yes | - #### WorkflowResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conversation_variables | [ [WorkflowConversationVariableResponse](#workflowconversationvariableresponse) ] | | Yes | | created_at | integer | | Yes | -| created_by | [SimpleAccount](#simpleaccount) | | No | +| created_by | [SimpleAccountResponse](#simpleaccountresponse) | | No | | environment_variables | [ [WorkflowEnvironmentVariableResponse](#workflowenvironmentvariableresponse) ] | | Yes | | features | object | | Yes | | graph | object | | Yes | @@ -20888,17 +21769,9 @@ can reuse its existing handler. | rag_pipeline_variables | [ [PipelineVariableResponse](#pipelinevariableresponse) ] | | Yes | | tool_published | boolean | | Yes | | updated_at | integer | | Yes | -| updated_by | [SimpleAccount](#simpleaccount) | | No | +| updated_by | [SimpleAccountResponse](#simpleaccountresponse) | | No | | version | string | | Yes | -#### WorkflowRestoreResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| hash | string | | Yes | -| result | string | | Yes | -| updated_at | integer | | Yes | - #### WorkflowRunCountQuery | Name | Type | Description | Required | @@ -20923,7 +21796,7 @@ can reuse its existing handler. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | created_at | integer | | No | -| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No | | created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | | created_by_role | string | | No | | elapsed_time | number | | No | @@ -20962,7 +21835,7 @@ can reuse its existing handler. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | created_at | integer | | No | -| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No | | elapsed_time | number | | No | | exceptions_count | integer | | No | | finished_at | integer | | No | @@ -21009,7 +21882,7 @@ can reuse its existing handler. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | created_at | integer | | No | -| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No | | created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | | created_by_role | string | | No | | elapsed_time | number | | No | @@ -21082,7 +21955,7 @@ Query parameters for workflow runs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | description | string | | Yes | -| icon | object | | Yes | +| icon | [ToolEmojiIcon](#toolemojiicon) | | Yes | | label | string | | Yes | | labels | [ string ] | | No | | name | string | | Yes | @@ -21096,6 +21969,22 @@ Query parameters for workflow runs. | ---- | ---- | ----------- | -------- | | workflow_tool_id | string | | Yes | +#### WorkflowToolDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | Yes | +| icon | [ToolEmojiIcon](#toolemojiicon) | | Yes | +| label | string | | Yes | +| name | string | | Yes | +| output_schema | object | | No | +| parameters | [ [WorkflowToolParameterConfiguration](#workflowtoolparameterconfiguration) ] | | Yes | +| privacy_policy | string | | No | +| synced | boolean | | Yes | +| tool | [ToolApiEntity](#toolapientity) | | Yes | +| workflow_app_id | string | | Yes | +| workflow_tool_id | string | | Yes | + #### WorkflowToolGetQuery | Name | Type | Description | Required | @@ -21124,7 +22013,7 @@ Workflow tool configuration | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | description | string | | Yes | -| icon | object | | Yes | +| icon | [ToolEmojiIcon](#toolemojiicon) | | Yes | | label | string | | Yes | | labels | [ string ] | | No | | name | string | | Yes | @@ -21202,28 +22091,21 @@ Workflow tool configuration | limit | integer,
**Default:** 20 | | No | | page | integer,
**Default:** 1 | | No | -#### WorkspaceListResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| data | [ [WorkspaceListItemResponse](#workspacelistitemresponse) ] | | Yes | -| has_more | boolean | | Yes | -| limit | integer | | Yes | -| page | integer | | Yes | -| total | integer | | Yes | - #### WorkspaceLogoUploadResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | id | string | | Yes | -#### WorkspaceMutationResponse +#### WorkspacePaginationResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| result | string | | Yes | -| tenant | [TenantInfoResponse](#tenantinforesponse) | | Yes | +| data | [ [WorkspaceListItemResponse](#workspacelistitemresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | #### WorkspacePermissionResponse @@ -21239,77 +22121,58 @@ Workflow tool configuration | ---- | ---- | ----------- | -------- | | permission_keys | [ string ] | | No | -#### _AccessPolicyList +#### WorkspaceTenantResultResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [AccessPolicy](#accesspolicy) ] | | No | -| pagination | [Pagination](#pagination) | | No | +| result | string | | Yes | +| tenant | [TenantInfoResponse](#tenantinforesponse) | | Yes | -#### _AnonymousInlineModel_744ff9cc03e6 +#### _AccessPolicyList | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| author_name | string | | No | -| created_at | long | | No | -| created_by | string | | No | -| description | string | | No | -| icon_info | object | | No | -| id | string | | No | -| is_published | boolean | | No | -| name | string | | No | -| tags | [ [_AnonymousInlineModel_7b8b49ca164e](#_anonymousinlinemodel_7b8b49ca164e) ] | | No | -| type | string | | No | -| updated_at | long | | No | -| updated_by | string | | No | -| use_count | integer | | No | -| version | integer | | No | +| data | [ [AccessPolicy](#accesspolicy) ] | | No | +| pagination | [Pagination](#pagination) | | No | -#### _AnonymousInlineModel_7b8b49ca164e +#### _MembersInRoleList | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| id | string | | No | -| name | string | | No | -| type | string | | No | +| data | [ [MembersInRole](#membersinrole) ] | | No | +| pagination | [Pagination](#pagination) | | No | -#### _AnonymousInlineModel_b0fd3f86d9d5 +#### _RBACRoleAccountList | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| email | string | | No | -| id | string | | No | -| name | string | | No | +| data | [ [RBACRoleAccount](#rbacroleaccount) ] | | No | +| pagination | [Pagination](#pagination) | | No | -#### _AnonymousInlineModel_b1954337d565 +#### _RBACRoleList | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| enable | boolean | | No | -| model_name | string | | No | -| model_provider_name | string | | No | -| summary_prompt | string | | No | +| data | [ [RBACRole](#rbacrole) ] | | No | +| pagination | [Pagination](#pagination) | | No | -#### _MembersInRoleList +#### core__entities__provider_entities__BasicProviderConfig__Type | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [MembersInRole](#membersinrole) ] | | No | -| pagination | [Pagination](#pagination) | | No | +| core__entities__provider_entities__BasicProviderConfig__Type | string | | | -#### _RBACRoleAccountList +#### core__plugin__entities__bundle__PluginBundleDependency__Type | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [RBACRoleAccount](#rbacroleaccount) ] | | No | -| pagination | [Pagination](#pagination) | | No | +| core__plugin__entities__bundle__PluginBundleDependency__Type | string | | | -#### _RBACRoleList +#### core__plugin__entities__parameters__PluginParameterAutoGenerate__Type | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [RBACRole](#rbacrole) ] | | No | -| pagination | [Pagination](#pagination) | | No | +| core__plugin__entities__parameters__PluginParameterAutoGenerate__Type | string | | | #### core__tools__entities__common_entities__I18nObject diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index 4bb6761c22eb78..08544f20a9dfe9 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -990,6 +990,12 @@ Pagination for GET /account/sessions. Strict (extra='forbid'). | last_used_at | string | | No | | prefix | string | | Yes | +#### SimpleResultResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | | Yes | + #### SupportedAppType App types the ``app`` usage face (``get app``) lists and filters. diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 8fc5e75e3cfeb9..dbc491dfc675c4 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -46,76 +46,6 @@ Deprecated legacy alias for creating a new document by providing text content. U | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - dataset API access or workspace access denied | | -### [DELETE] /datasets/{dataset_id}/documents/{document_id} -**Delete Document** - -Permanently delete a document and all its chunks from the knowledge base. - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | -| document_id | path | Document ID. | Yes | string (uuid) | - -#### Responses - -| Code | Description | -| ---- | ----------- | -| 204 | Success. | -| 400 | `document_indexing` : Cannot delete document during indexing. | -| 401 | Unauthorized - invalid API token | -| 403 | `archived_document_immutable` : The archived document is not editable. | -| 404 | `not_found` : Document Not Exists. | - -### [GET] /datasets/{dataset_id}/documents/{document_id} -**Get Document** - -Retrieve detailed information about a specific document, including its indexing status, metadata, and processing statistics. - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | -| document_id | path | Document ID. | Yes | string (uuid) | -| metadata | query | `all` returns all fields including metadata. `only` returns only `id`, `doc_type`, and `doc_metadata`. `without` returns all fields except `doc_metadata`. | No | string,
**Available values:** "all", "only", "without",
**Default:** all | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Document details. The response shape varies based on the `metadata` query parameter. When `metadata` is `only`, only `id`, `doc_type`, and `doc_metadata` are returned. When `metadata` is `without`, `doc_type` and `doc_metadata` are omitted. | **application/json**: [DocumentDetailResponse](#documentdetailresponse)
| -| 400 | `invalid_metadata` : Invalid metadata value for the specified key. | | -| 401 | Unauthorized - invalid API token | | -| 403 | `forbidden` : No permission. | | -| 404 | `not_found` : Document not found. | | - -### [PATCH] /datasets/{dataset_id}/documents/{document_id} -Update an existing document by uploading a file - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | -| document_id | path | Document ID. | Yes | string (uuid) | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| No | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - dataset API access or workspace access denied | | -| 404 | Document not found | | - ### ~~[POST] /datasets/{dataset_id}/documents/{document_id}/update_by_text~~ ***DEPRECATED*** @@ -237,7 +167,7 @@ Retrieves the status of an asynchronous annotation reply configuration job start | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Successfully retrieved task status. | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| +| 200 | Successfully retrieved task status. | **application/json**: [AnnotationJobStatusDetailResponse](#annotationjobstatusdetailresponse)
| | 400 | `invalid_param` : The specified job does not exist. | | | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - token scope, app, dataset, or workspace access denied | | @@ -392,15 +322,15 @@ Send a request to the chat application. #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `ChatCompletionResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of Server-Sent Events. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | - `app_unavailable` : App unavailable or misconfigured. - `not_chat_app` : App mode does not match the API route. - `conversation_completed` : The conversation has ended. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Text generation failed. | | -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | -| 404 | `not_found` : Conversation does not exist. | | -| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | -| 500 | `internal_server_error` : Internal server error. | | +| Code | Description | +| ---- | ----------- | +| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `ChatCompletionResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of Server-Sent Events. | +| 400 | - `app_unavailable` : App unavailable or misconfigured. - `not_chat_app` : App mode does not match the API route. - `conversation_completed` : The conversation has ended. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Text generation failed. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | +| 404 | `not_found` : Conversation does not exist. | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | +| 500 | `internal_server_error` : Internal server error. | ### [POST] /chat-messages/{task_id}/stop **Stop Chat Message Generation** @@ -539,15 +469,15 @@ Send a request to the chat application. #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `ChatCompletionResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of Server-Sent Events. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | - `app_unavailable` : App unavailable or misconfigured. - `not_chat_app` : App mode does not match the API route. - `conversation_completed` : The conversation has ended. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Text generation failed. | | -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | -| 404 | `not_found` : Conversation does not exist. | | -| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | -| 500 | `internal_server_error` : Internal server error. | | +| Code | Description | +| ---- | ----------- | +| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `ChatCompletionResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of Server-Sent Events. | +| 400 | - `app_unavailable` : App unavailable or misconfigured. - `not_chat_app` : App mode does not match the API route. - `conversation_completed` : The conversation has ended. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Text generation failed. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | +| 404 | `not_found` : Conversation does not exist. | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | +| 500 | `internal_server_error` : Internal server error. | ### [POST] /chat-messages/{task_id}/stop **Stop Chat Message Generation** @@ -615,15 +545,15 @@ Send a request to the text generation application. #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `CompletionResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkCompletionEvent` objects. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | - `app_unavailable` : App unavailable or misconfigured. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Text generation failed. | | -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | -| 404 | Conversation not found | | -| 429 | `too_many_requests` : Too many concurrent requests for this app. | | -| 500 | `internal_server_error` : Internal server error. | | +| Code | Description | +| ---- | ----------- | +| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `CompletionResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkCompletionEvent` objects. | +| 400 | - `app_unavailable` : App unavailable or misconfigured. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Text generation failed. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | +| 404 | Conversation not found | +| 429 | `too_many_requests` : Too many concurrent requests for this app. | +| 500 | `internal_server_error` : Internal server error. | ### [POST] /completion-messages/{task_id}/stop **Stop Completion Message Generation** @@ -1046,12 +976,12 @@ Execute a single datasource node within the knowledge pipeline. Returns a stream #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Streaming response with node execution events. | **text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - dataset API access or workspace access denied | | -| 404 | `not_found` : Dataset not found. | | +| Code | Description | +| ---- | ----------- | +| 200 | Streaming response with node execution events. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - dataset API access or workspace access denied | +| 404 | `not_found` : Dataset not found. | ### [POST] /datasets/{dataset_id}/pipeline/run **Run Pipeline** @@ -1072,13 +1002,13 @@ Execute the full knowledge pipeline for a knowledge base. Supports both streamin #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Pipeline execution result. Format depends on `response_mode`: streaming returns a `text/event-stream`, blocking returns a JSON object. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| -| 401 | Unauthorized - invalid API token | | -| 403 | `forbidden` : Forbidden. | | -| 404 | `not_found` : Dataset not found. | | -| 500 | `pipeline_run_error` : Pipeline execution failed. | | +| Code | Description | +| ---- | ----------- | +| 200 | Pipeline execution result. Format depends on `response_mode`: streaming returns a `text/event-stream`, blocking returns a JSON object. | +| 401 | Unauthorized - invalid API token | +| 403 | `forbidden` : Forbidden. | +| 404 | `not_found` : Dataset not found. | +| 500 | `pipeline_run_error` : Pipeline execution failed. | --- ## default @@ -1439,7 +1369,9 @@ Retrieve detailed information about a specific document, including its indexing | 404 | `not_found` : Document not found. | | ### [PATCH] /datasets/{dataset_id}/documents/{document_id} -Update an existing document by uploading a file +**Update Document by File** + +Update an existing document by uploading a new file. Re-triggers indexing — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. #### Parameters @@ -1458,7 +1390,8 @@ Update an existing document by uploading a file | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| +| 200 | Document updated successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| +| 400 | - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, file too large, unsupported file type, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - dataset API access or workspace access denied | | | 404 | Document not found | | @@ -2220,15 +2153,15 @@ Execute a workflow. Cannot be executed without a published workflow. #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | - `not_workflow_app` : App mode does not match the API route. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Invalid parameter value. | | -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | -| 404 | Workflow not found | | -| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | -| 500 | `internal_server_error` : Internal server error. | | +| Code | Description | +| ---- | ----------- | +| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | +| 400 | - `not_workflow_app` : App mode does not match the API route. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Invalid parameter value. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | +| 404 | Workflow not found | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | +| 500 | `internal_server_error` : Internal server error. | ### [GET] /workflows/run/{workflow_run_id} **Get Workflow Run Detail** @@ -2297,15 +2230,15 @@ Execute a specific workflow version identified by its ID. Useful for running a p #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | - `not_workflow_app` : App mode does not match the API route. - `bad_request` : Workflow is a draft or has an invalid ID format. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Required parameter missing or invalid. | | -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | -| 404 | `not_found` : Workflow not found. | | -| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | -| 500 | `internal_server_error` : Internal server error. | | +| Code | Description | +| ---- | ----------- | +| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | +| 400 | - `not_workflow_app` : App mode does not match the API route. - `bad_request` : Workflow is a draft or has an invalid ID format. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Required parameter missing or invalid. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | +| 404 | `not_found` : Workflow not found. | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | +| 500 | `internal_server_error` : Internal server error. | --- ## default @@ -2351,7 +2284,7 @@ Retrieve the list of available models by type. Primarily used to query `text-emb | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | No | +| answer | string | | No | | created_at | integer | | No | | hit_count | integer | | No | | id | string | | Yes | @@ -2364,13 +2297,20 @@ Retrieve the list of available models by type. Primarily used to query `text-emb | answer | string | Annotation answer. | Yes | | question | string | Annotation question. | Yes | -#### AnnotationJobStatusResponse +#### AnnotationJobStatusDetailResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | error_msg | string | | No | | job_id | string | | Yes | -| job_status | string | | Yes | +| job_status | string
string | | Yes | + +#### AnnotationJobStatusResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| job_id | string | | Yes | +| job_status | string
string | | Yes | #### AnnotationList @@ -2960,7 +2900,7 @@ Enum class for custom configuration status. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| credentials | [ [DatasourceCredentialInfoResponse](#datasourcecredentialinforesponse) ] | | Yes | +| credentials | [ [DatasourceCredentialInfoResponse](#datasourcecredentialinforesponse) ] | | No | | datasource_type | string | | No | | node_id | string | | No | | plugin_id | string | | No | @@ -2994,7 +2934,7 @@ Request payload for bulk downloading documents as a zip archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | archived | boolean | | No | -| average_segment_length | number | | No | +| average_segment_length | integer
number | | No | | completed_at | integer | | No | | created_at | integer | | No | | created_by | string | | No | @@ -3008,7 +2948,7 @@ Request payload for bulk downloading documents as a zip archive. | display_status | string | | No | | doc_form | string | | No | | doc_language | string | | No | -| doc_metadata | [ [DocumentMetadataResponse](#documentmetadataresponse) ] | | No | +| doc_metadata | [ [DocumentMetadataResponse](#documentmetadataresponse) ]
object | | No | | doc_type | string | | No | | document_process_rule | object | | No | | enabled | boolean | | No | @@ -3264,12 +3204,6 @@ Enum class for fetch from. | ---- | ---- | ----------- | -------- | | FormInputConfig | [ParagraphInputConfig](#paragraphinputconfig)
[SelectInputConfig](#selectinputconfig)
[FileInputConfig](#fileinputconfig)
[FileListInputConfig](#filelistinputconfig) | | | -#### GeneratedAppResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| GeneratedAppResponse | | | | - #### HitTestingChildChunk | Name | Type | Description | Required | @@ -3454,7 +3388,7 @@ Model class for i18n object. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| JSONValue | string
integer
number
boolean
object
[ object ] | | | +| JSONValue | | | | #### JSONValueType @@ -3937,7 +3871,7 @@ Model class for provider with models response. | output_variable_name | string | | Yes | | type | string | | No | -#### SimpleAccount +#### SimpleAccountResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -4147,7 +4081,7 @@ in form definiton, or a variable while the workflow is running. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | created_at | integer | | No | -| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No | | created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | | created_by_role | string | | No | | created_from | string | | No | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 0f368895ab6fd4..42c471330f8009 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -21,7 +21,7 @@ Convert audio file to text using speech-to-text service. | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)
| +| 200 | Success | **application/json**: [AudioToTextResponse](#audiototextresponse)
| | 400 | Bad Request | | | 401 | Unauthorized | | | 403 | Forbidden | | @@ -40,14 +40,14 @@ Create a chat message for conversational applications. #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | App Not Found | | -| 500 | Internal Server Error | | +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | ### [POST] /chat-messages/{task_id}/stop Stop a running chat message task. @@ -80,14 +80,14 @@ Create a completion message for text generation applications. #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | App Not Found | | -| 500 | Internal Server Error | | +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | ### [POST] /completion-messages/{task_id}/stop Stop a running completion message task. @@ -346,23 +346,29 @@ Verify password reset token validity ### [GET] /form/human_input/{form_token} **Get human input form definition by token** +Get a human input form definition by token GET /api/form/human_input/ #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| form_token | path | | Yes | string | +| form_token | path | Human input form token | Yes | string | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)
| +| 200 | Form retrieved successfully | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)
| +| 403 | Forbidden | | +| 404 | Form not found | | +| 412 | Form already submitted or expired | | +| 429 | Too many requests | | ### [POST] /form/human_input/{form_token} **Submit human input form by token** +Submit a human input form by token POST /api/form/human_input/ Request body: @@ -377,7 +383,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| form_token | path | | Yes | string | +| form_token | path | Human input form token | Yes | string | #### Request Body @@ -389,24 +395,32 @@ Request body: | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| +| 200 | Form submitted successfully | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| +| 400 | Bad request - invalid submission data | | +| 404 | Form not found | | +| 412 | Form already submitted or expired | | +| 429 | Too many requests | | ### [POST] /form/human_input/{form_token}/upload-token **Issue an upload token for a human input form** +Issue an upload token for an active human input form POST /api/form/human_input//upload-token #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| form_token | path | | Yes | string | +| form_token | path | Human input form token | Yes | string | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [HumanInputUploadTokenResponse](#humaninputuploadtokenresponse)
| +| 200 | Upload token issued successfully | **application/json**: [HumanInputUploadTokenResponse](#humaninputuploadtokenresponse)
| +| 404 | Form not found | | +| 412 | Form already submitted or expired | | +| 429 | Too many requests | | ### [POST] /human-input-forms/files **Upload one local file or remote URL file for a HITL human input form** @@ -526,14 +540,14 @@ Generate a new completion similar to an existing message (completion apps only). #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Bad Request - Not a completion app or feature disabled | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | Message Not Found | | -| 500 | Internal Server Error | | +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request - Not a completion app or feature disabled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Message Not Found | +| 500 | Internal Server Error | ### [GET] /messages/{message_id}/suggested-questions Get suggested follow-up questions after a message (chat apps only). @@ -600,7 +614,7 @@ Get authentication passport for web application access | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Passport retrieved successfully | **application/json**: [AccessTokenData](#accesstokendata)
| +| 200 | Passport retrieved successfully | **application/json**: [PassportAccessTokenResponse](#passportaccesstokenresponse)
| | 401 | Unauthorized - missing app code or invalid authentication | | | 404 | Application or user not found | | @@ -752,7 +766,7 @@ Retrieve app site information and configuration. | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [AppSiteInfoResponse](#appsiteinforesponse)
| +| 200 | Success | **application/json**: [WebAppSiteResponse](#webappsiteresponse)
| | 400 | Bad Request | | | 401 | Unauthorized | | | 403 | Forbidden | | @@ -799,13 +813,13 @@ Convert text to audio using text-to-speech service. #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [AudioBinaryResponse](#audiobinaryresponse)
| -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 500 | Internal Server Error | | +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal Server Error | ### [GET] /webapp/access-mode Retrieve the access mode for a web application (public or restricted). @@ -856,14 +870,14 @@ Execute a workflow with provided inputs and files. #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | App Not Found | | -| 500 | Internal Server Error | | +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | ### [POST] /workflows/tasks/{task_id}/stop **Stop workflow task** @@ -967,58 +981,7 @@ Returns Server-Sent Events stream. | ---- | ---- | ----------- | -------- | | appId | string | Application ID | Yes | -#### AppSiteInfoResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| app_id | string | | Yes | -| can_replace_logo | boolean | | Yes | -| custom_config | object | | No | -| enable_site | boolean | | Yes | -| end_user_id | string | | No | -| model_config | [AppSiteModelConfigResponse](#appsitemodelconfigresponse) | | No | -| plan | string | | No | -| site | [AppSiteResponse](#appsiteresponse) | | Yes | - -#### AppSiteModelConfigResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| model | | | Yes | -| more_like_this | | | Yes | -| opening_statement | string | | No | -| pre_prompt | string | | No | -| suggested_questions | | | Yes | -| suggested_questions_after_answer | | | Yes | -| user_input_form | | | Yes | - -#### AppSiteResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| chat_color_theme | string | | No | -| chat_color_theme_inverted | boolean | | No | -| copyright | string | | No | -| custom_disclaimer | string | | No | -| default_language | string | | No | -| description | string | | No | -| icon | string | | No | -| icon_background | string | | No | -| icon_type | string | | No | -| icon_url | string | | No | -| privacy_policy | string | | No | -| prompt_public | boolean | | No | -| show_workflow_steps | boolean | | No | -| title | string | | No | -| use_icon_as_answer_icon | boolean | | No | - -#### AudioBinaryResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| AudioBinaryResponse | string | | | - -#### AudioTranscriptResponse +#### AudioToTextResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -1216,12 +1179,6 @@ Button styles for user actions. | ---- | ---- | ----------- | -------- | | FormInputConfig | [ParagraphInputConfig](#paragraphinputconfig)
[SelectInputConfig](#selectinputconfig)
[FileInputConfig](#fileinputconfig)
[FileListInputConfig](#filelistinputconfig) | | | -#### GeneratedAppResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| GeneratedAppResponse | | | | - #### HumanInputContent | Name | Type | Description | Required | @@ -1260,11 +1217,11 @@ Parsed multipart form fields for HITL uploads. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | expiration_time | integer | | Yes | -| form_content | | | Yes | -| inputs | | | Yes | +| form_content | string | | Yes | +| inputs | [ [FormInputConfig](#forminputconfig) ] | | Yes | | resolved_default_values | object | | Yes | -| site | object | | No | -| user_actions | | | Yes | +| site | [WebAppSiteResponse](#webappsiteresponse) | | No | +| user_actions | [ [UserActionConfig](#useractionconfig) ] | | Yes | #### HumanInputFormSubmissionData @@ -1306,7 +1263,7 @@ Parsed multipart form fields for HITL uploads. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| JSONValue | string
integer
number
boolean
object
[ object ] | | | +| JSONValue | | | | #### JSONValueType @@ -1429,6 +1386,12 @@ Form input definition. | text_to_speech | [JSONObject](#jsonobject) | | Yes | | user_input_form | [ [JSONObject](#jsonobject) ] | | Yes | +#### PassportAccessTokenResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | Yes | + #### PassportQuery | Name | Type | Description | Required | @@ -1681,6 +1644,26 @@ in form definiton, or a variable while the workflow is running. | ---- | ---- | ----------- | -------- | | protocol | string | | Yes | +#### WebAppCustomConfigResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| remove_webapp_brand | boolean | | Yes | +| replace_webapp_logo | string | | No | + +#### WebAppSiteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | Yes | +| can_replace_logo | boolean | | Yes | +| custom_config | [WebAppCustomConfigResponse](#webappcustomconfigresponse) | | No | +| enable_site | boolean | | Yes | +| end_user_id | string | | No | +| model_config | [WebModelConfigResponse](#webmodelconfigresponse) | | No | +| plan | string | | Yes | +| site | [WebSiteResponse](#websiteresponse) | | Yes | + #### WebMessageInfiniteScrollPagination | Name | Type | Description | Required | @@ -1709,6 +1692,38 @@ in form definiton, or a variable while the workflow is running. | retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes | | status | string | | Yes | +#### WebModelConfigResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model | | | No | +| more_like_this | | | No | +| opening_statement | string | | No | +| pre_prompt | string | | No | +| suggested_questions | | | No | +| suggested_questions_after_answer | | | No | +| user_input_form | | | No | + +#### WebSiteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chat_color_theme | string | | No | +| chat_color_theme_inverted | boolean | | Yes | +| copyright | string | | No | +| custom_disclaimer | string | | No | +| default_language | string | | No | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| icon_url | string | | Yes | +| privacy_policy | string | | No | +| prompt_public | boolean | | No | +| show_workflow_steps | boolean | | No | +| title | string | | Yes | +| use_icon_as_answer_icon | boolean | | No | + #### WorkflowRunPayload | Name | Type | Description | Required | diff --git a/api/services/file_service.py b/api/services/file_service.py index e41d74ad3ebae8..abd253ec8b2447 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -173,7 +173,7 @@ def upload_text(self, text: str, text_name: str, user_id: str, tenant_id: str) - return upload_file - def get_file_preview(self, file_id: str, tenant_id: str): + def get_file_preview(self, file_id: str, tenant_id: str) -> str: """ Return a short text preview extracted from a document file. """ @@ -191,9 +191,7 @@ def get_file_preview(self, file_id: str, tenant_id: str): raise UnsupportedFileTypeError() text = ExtractProcessor.load_from_upload_file(upload_file, return_text=True) - text = text[0:PREVIEW_WORDS_LIMIT] if text else "" - - return text + return text[0:PREVIEW_WORDS_LIMIT] if text else "" def get_image_preview(self, file_id: str, timestamp: str, nonce: str, sign: str): result = file_helpers.verify_image_signature( diff --git a/api/services/snippet_service.py b/api/services/snippet_service.py index 75282db9d4cfb5..803f079c2ce47e 100644 --- a/api/services/snippet_service.py +++ b/api/services/snippet_service.py @@ -1,6 +1,6 @@ import json import logging -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Generator, Mapping, Sequence from contextlib import contextmanager from datetime import UTC, datetime from typing import Any @@ -70,7 +70,7 @@ def __init__( self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) @contextmanager - def _session_scope(self) -> Iterator[Session]: + def _session_scope(self) -> Generator[Session]: current_session = getattr(self, "_session", None) if current_session is not None: yield current_session @@ -627,8 +627,6 @@ def publish_workflow( conversation_variables=[], rag_pipeline_variables=draft_workflow.rag_pipeline_variables, kind=WorkflowKind.SNIPPET.value, - marked_name="", - marked_comment="", ) session.add(workflow) diff --git a/api/services/summary_index_service.py b/api/services/summary_index_service.py index 1657cfdd2566c6..97aee92812ce39 100644 --- a/api/services/summary_index_service.py +++ b/api/services/summary_index_service.py @@ -1423,6 +1423,7 @@ def get_document_summary_status_detail( - generating: Number of summaries being generated - error: Number of summaries with errors - not_started: Number of segments without summary records + - timeout: Number of summaries that timed out - summaries: List of summary records with status and content preview """ from services.dataset_service import SegmentService diff --git a/api/services/tools/api_tools_manage_service.py b/api/services/tools/api_tools_manage_service.py index 5ff2c2174925cc..05dda0b1ba6c29 100644 --- a/api/services/tools/api_tools_manage_service.py +++ b/api/services/tools/api_tools_manage_service.py @@ -1,8 +1,9 @@ import json import logging -from typing import Any, TypedDict, cast +from typing import Any, Literal, TypedDict, cast from httpx import get +from pydantic import TypeAdapter from sqlalchemy import select from sqlalchemy.orm import sessionmaker @@ -22,7 +23,6 @@ from core.tools.utils.encryption import create_tool_provider_encrypter from core.tools.utils.parser import ApiBasedToolSchemaParser from extensions.ext_database import db -from graphon.model_runtime.utils.encoders import jsonable_encoder from models.tools import ApiToolProvider from services.tools.tools_transform_service import ToolTransformService @@ -36,6 +36,27 @@ class ApiSchemaParseResult(TypedDict): warning: dict[str, str] +class ApiToolPreviewResult(TypedDict, total=False): + result: str + error: str + + +class RemoteSchemaResult(TypedDict): + schema: str + + +class SimpleSuccessResult(TypedDict): + result: Literal["success"] + + +def _dump_api_tool_bundles(tool_bundles: list[ApiToolBundle]) -> list[dict[str, Any]]: + return cast(list[dict[str, Any]], TypeAdapter(list[ApiToolBundle]).dump_python(tool_bundles, mode="json")) + + +def _dump_provider_configs(configs: list[ProviderConfig]) -> list[dict[str, Any]]: + return cast(list[dict[str, Any]], TypeAdapter(list[ProviderConfig]).dump_python(configs, mode="json")) + + class ApiToolManageService: @staticmethod def parser_api_schema(schema: str) -> ApiSchemaParseResult: @@ -80,14 +101,12 @@ def parser_api_schema(schema: str) -> ApiSchemaParseResult: return cast( ApiSchemaParseResult, - jsonable_encoder( - { - "schema_type": schema_type, - "parameters_schema": tool_bundles, - "credentials_schema": credentials_schema, - "warning": warnings, - } - ), + { + "schema_type": schema_type.value, + "parameters_schema": _dump_api_tool_bundles(tool_bundles), + "credentials_schema": _dump_provider_configs(credentials_schema), + "warning": warnings, + }, ) except Exception as e: raise ValueError(f"invalid schema: {str(e)}") @@ -118,7 +137,7 @@ def create_api_tool_provider( privacy_policy: str, custom_disclaimer: str, labels: list[str], - ) -> dict[str, Any]: + ) -> SimpleSuccessResult: """ Create a new API tool provider. @@ -169,7 +188,7 @@ def create_api_tool_provider( schema=schema, description=extra_info.get("description", ""), schema_type_str=schema_type, - tools_str=json.dumps(jsonable_encoder(tool_bundles)), + tools_str=json.dumps(_dump_api_tool_bundles(tool_bundles)), credentials_str="{}", privacy_policy=privacy_policy, custom_disclaimer=custom_disclaimer, @@ -201,7 +220,7 @@ def create_api_tool_provider( return {"result": "success"} @staticmethod - def get_api_tool_provider_remote_schema(user_id: str, tenant_id: str, url: str): + def get_api_tool_provider_remote_schema(user_id: str, tenant_id: str, url: str) -> RemoteSchemaResult: """ get api tool provider remote schema """ @@ -276,7 +295,7 @@ def update_api_tool_provider( privacy_policy: str | None, custom_disclaimer: str, labels: list[str], - ) -> dict[str, Any]: + ) -> SimpleSuccessResult: """ Update an existing API tool provider. @@ -322,7 +341,7 @@ def update_api_tool_provider( provider.schema = schema provider.description = extra_info.get("description", "") provider.schema_type_str = schema_type - provider.tools_str = json.dumps(jsonable_encoder(tool_bundles)) + provider.tools_str = json.dumps(_dump_api_tool_bundles(tool_bundles)) provider.privacy_policy = privacy_policy provider.custom_disclaimer = custom_disclaimer @@ -365,7 +384,7 @@ def update_api_tool_provider( return {"result": "success"} @staticmethod - def delete_api_tool_provider(user_id: str, tenant_id: str, provider_name: str): + def delete_api_tool_provider(user_id: str, tenant_id: str, provider_name: str) -> SimpleSuccessResult: """ Delete an API tool provider. @@ -413,9 +432,9 @@ def test_api_tool_preview( tool_name: str, credentials: dict[str, Any], parameters: dict[str, Any], - schema_type: ApiProviderSchemaType, + schema_type: ApiProviderSchemaType | str, schema: str, - ) -> dict[str, Any]: + ) -> ApiToolPreviewResult: """ Test an API tool before adding the API tool provider. @@ -464,7 +483,7 @@ def test_api_tool_preview( schema=schema, description="", schema_type_str=ApiProviderSchemaType.OPENAPI, - tools_str=json.dumps(jsonable_encoder(tool_bundles)), + tools_str=json.dumps(_dump_api_tool_bundles(tool_bundles)), credentials_str=json.dumps(credentials), ) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 262ccc18f83322..d5642ccc9699e4 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -1004,6 +1004,9 @@ def get_human_input_form_preview( """ Build a human input form preview for a draft workflow. + Preview responses are non-actionable: they mirror the live pause payload + shape without creating a persisted form, recipient token, or expiration. + Args: app_model: Target application model. account: Current account. diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py index be13f993a10a62..78eb43d7237dae 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py @@ -62,6 +62,7 @@ from controllers.console.app.workflow_trigger import Parser, ParserEnable from models.account import Account, AccountStatus from models.model import AppMode +from services.workflow_draft_variable_service import WorkflowDraftVariableList from tests.test_containers_integration_tests.controllers.console.helpers import ( authenticate_console_client, create_console_account_and_tenant, @@ -512,7 +513,7 @@ def __init__(self, session): self.session = session def list_variables_without_values(self, **_kwargs): - return {"items": [], "total": 0} + return WorkflowDraftVariableList(variables=[], total=0) monkeypatch.setattr(workflow_draft_variable_module, "sessionmaker", DummySessionMaker) diff --git a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py index bdec903ef33b89..4b8186f301741c 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py @@ -607,7 +607,11 @@ def test_recommended_plugins(self, app: Flask) -> None: method = unwrap(api.get) service = MagicMock() - service.get_recommended_plugins.return_value = [{"id": "p1"}] + recommended_plugins = { + "installed_recommended_plugins": [{"id": "p1"}], + "uninstalled_recommended_plugins": [{"id": "p2"}], + } + service.get_recommended_plugins.return_value = recommended_plugins user = make_account() tenant_id = "tenant-1" @@ -619,7 +623,7 @@ def test_recommended_plugins(self, app: Flask) -> None: ), ): result = method(api, tenant_id, user) - assert result == [{"id": "p1"}] + assert result == recommended_plugins service.get_recommended_plugins.assert_called_once_with("all", user, tenant_id) @@ -826,7 +830,7 @@ def test_delete_success(self, app: Flask) -> None: result = method(api, pipeline, "old-workflow") workflow_service.delete_workflow.assert_called_once() - assert result == (None, 204) + assert result == ("", 204) def test_delete_active_workflow_rejected(self, app: Flask) -> None: api = RagPipelineByIdApi() diff --git a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py index 8739ca28bd33e8..37aa68fc00bd21 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py +++ b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py @@ -44,6 +44,7 @@ ToolWorkflowProviderUpdateApi, is_valid_url, ) +from core.tools.entities.api_entities import ToolProviderApiEntity as CoreToolProviderApiEntity from models.account import Account, TenantAccountRole from services.tools.mcp_tools_manage_service import ReconnectResult from tests.test_containers_integration_tests.controllers.console.helpers import ( @@ -60,6 +61,148 @@ def empty_list() -> list[object]: return [] +def emoji_icon() -> dict[str, str]: + return {"content": "tool", "background": "#252525"} + + +def i18n(text: str) -> dict[str, str]: + return {"en_US": text} + + +def tool_payload(name: str = "ping") -> dict[str, object]: + return { + "author": "langgenius", + "name": name, + "label": i18n(name.title()), + "description": i18n(f"{name} description"), + "parameters": [], + "labels": ["utilities"], + "output_schema": {}, + } + + +def provider_payload( + *, + provider_id: str = "provider-1", + name: str = "provider", + provider_type: str = "builtin", + tools: list[dict[str, object]] | None = None, +) -> dict[str, object]: + return { + "id": provider_id, + "author": "langgenius", + "name": name, + "description": i18n(f"{name} description"), + "icon": emoji_icon(), + "icon_dark": emoji_icon(), + "label": i18n(name.title()), + "type": provider_type, + "masked_credentials": {"api_key": "[__HIDDEN__]"}, + "original_credentials": {"api_key": "sk-secret"}, + "is_team_authorization": False, + "allow_delete": True, + "plugin_id": "langgenius/provider", + "plugin_unique_identifier": "langgenius/provider:1.0.0", + "tools": tools or [tool_payload()], + "labels": ["utilities"], + "server_url": "", + "updated_at": 1710000000, + "server_identifier": "", + "masked_headers": None, + "original_headers": None, + "authentication": None, + "is_dynamic_registration": True, + "configuration": None, + "identity_mode": "off", + "workflow_app_id": None, + } + + +def provider_entity( + *, + provider_id: str = "provider-1", + name: str = "provider", + provider_type: str = "builtin", + tools: list[dict[str, object]] | None = None, +) -> CoreToolProviderApiEntity: + return CoreToolProviderApiEntity.model_validate( + provider_payload(provider_id=provider_id, name=name, provider_type=provider_type, tools=tools) + ) + + +def credential_payload() -> dict[str, object]: + return { + "id": "credential-1", + "name": "Default credential", + "provider": "provider", + "credential_type": "api-key", + "is_default": True, + "credentials": {"api_key": "masked"}, + "visibility": "all_team_members", + "created_by": "user-1", + "partial_member_list": [], + "from_other_member": False, + } + + +def provider_config_payload() -> dict[str, object]: + return {"type": "secret-input", "name": "api_key", "required": True} + + +def api_tool_bundle_payload() -> dict[str, object]: + return { + "server_url": "https://api.example.com", + "method": "get", + "summary": "Ping", + "operation_id": "ping", + "parameters": [], + "author": "langgenius", + "icon": None, + "openapi": {"operationId": "ping"}, + "output_schema": {}, + } + + +def api_provider_detail_payload() -> dict[str, object]: + return { + "schema_type": "openapi", + "schema": "{}", + "tools": [api_tool_bundle_payload()], + "icon": emoji_icon(), + "description": "API provider", + "credentials": {}, + "privacy_policy": "", + "custom_disclaimer": "", + "labels": ["utilities"], + } + + +def credential_info_payload() -> dict[str, object]: + return { + "supported_credential_types": ["api-key", "oauth2"], + "is_oauth_custom_client_enabled": False, + "credentials": [credential_payload()], + } + + +def oauth_client_schema_payload() -> dict[str, object]: + return { + "schema": [provider_config_payload()], + "is_oauth_custom_client_enabled": False, + "is_system_oauth_params_exists": True, + "client_params": {"client_id": "masked"}, + "redirect_uri": "https://console.example.com/oauth/callback", + } + + +def tool_label_payload() -> dict[str, object]: + return { + "name": "utilities", + "label": i18n("Utilities"), + "icon": "wrench", + } + + @pytest.fixture def _mock_cache() -> None: return @@ -127,7 +270,7 @@ def test_create_mcp_provider_populates_tools( with ( patch( "services.tools.tools_transform_service.ToolTransformService.mcp_provider_to_user_provider", - return_value={"id": "provider-1", "tools": [{"name": "ping"}]}, + return_value=provider_entity(provider_id="provider-1", provider_type="mcp", tools=[tool_payload()]), autospec=True, ), ): @@ -138,13 +281,15 @@ def test_create_mcp_provider_populates_tools( content_type="application/json", ) - # Assert - assert resp.status_code == 200 - body = resp.get_json() - assert body.get("id") == "provider-1" - # 若 transform 后包含 tools 字段,确保非空 - assert isinstance(body.get("tools"), list) - assert body["tools"] + # Assert + assert resp.status_code == 200 + body = resp.get_json() + assert body.get("id") == "provider-1" + assert body["team_credentials"] == {"api_key": "[__HIDDEN__]"} + assert "masked_credentials" not in body + assert "original_credentials" not in body + assert isinstance(body.get("tools"), list) + assert body["tools"] class TestUtils: @@ -170,10 +315,16 @@ def test_get_success(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.ToolCommonService.list_tool_providers", - return_value=["p1"], + return_value=[provider_entity(provider_id="p1").to_dict()], ), ): - assert method(api, "t1", make_account(id="u1")) == ["p1"] + result = method(api, "t1", make_account(id="u1")) + + assert result[0]["id"] == "p1" + assert result[0]["team_credentials"] == {"api_key": "[__HIDDEN__]"} + assert "masked_credentials" not in result[0] + assert "original_credentials" not in result[0] + assert result[0]["tools"][0]["name"] == "ping" class TestBuiltinProviderApis: @@ -189,10 +340,10 @@ def test_list_tools(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.list_builtin_tool_provider_tools", - return_value=[{"a": 1}], + return_value=[tool_payload()], ), ): - assert method(api, "t1", "provider") == [{"a": 1}] + assert method(api, "t1", "provider")[0]["name"] == "ping" def test_info(self, app: Flask) -> None: api = ToolBuiltinProviderInfoApi() @@ -202,10 +353,15 @@ def test_info(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.get_builtin_tool_provider_info", - return_value={"x": 1}, + return_value=provider_entity(), ), ): - assert method(api, "t1", "provider") == {"x": 1} + result = method(api, "t1", "provider") + + assert result["id"] == "provider-1" + assert result["team_credentials"] == {"api_key": "[__HIDDEN__]"} + assert "masked_credentials" not in result + assert "original_credentials" not in result def test_delete(self, app: Flask) -> None: api = ToolBuiltinProviderDeleteApi() @@ -240,10 +396,10 @@ def test_add_success(self, app: Flask) -> None: app.test_request_context("/", json=payload), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.add_builtin_tool_provider", - return_value={"id": 1}, + return_value={"result": "success"}, ), ): - assert method(api, "t", make_account(), "provider")["id"] == 1 + assert method(api, "t", make_account(), "provider")["result"] == "success" def test_update(self, app: Flask) -> None: api = ToolBuiltinProviderUpdateApi() @@ -255,10 +411,10 @@ def test_update(self, app: Flask) -> None: app.test_request_context("/", json=payload), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.update_builtin_tool_provider", - return_value={"ok": True}, + return_value={"result": "success"}, ), ): - assert method(api, "t", make_account(), "provider")["ok"] + assert method(api, "t", make_account(), "provider")["result"] == "success" def test_get_credentials(self, app: Flask) -> None: api = ToolBuiltinProviderGetCredentialsApi() @@ -268,10 +424,10 @@ def test_get_credentials(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.get_builtin_tool_provider_credentials", - return_value={"k": "v"}, + return_value=[credential_payload()], ), ): - assert method(api, "t", make_account(id="user-1"), "provider") == {"k": "v"} + assert method(api, "t", make_account(id="user-1"), "provider")[0]["id"] == "credential-1" def test_icon(self, app: Flask) -> None: api = ToolBuiltinProviderIconApi() @@ -295,10 +451,10 @@ def test_credentials_schema(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.list_builtin_provider_credentials_schema", - return_value={"schema": {}}, + return_value=[provider_config_payload()], ), ): - assert method(api, "t", "provider", "oauth2") == {"schema": {}} + assert method(api, "t", "provider", "oauth2")[0]["name"] == "api_key" def test_set_default_credential(self, app: Flask) -> None: api = ToolBuiltinProviderSetDefaultApi() @@ -308,10 +464,10 @@ def test_set_default_credential(self, app: Flask) -> None: app.test_request_context("/", json={"id": "c1"}), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.set_default_provider", - return_value={"ok": True}, + return_value={"result": "success"}, ), ): - assert method(api, "t", "provider")["ok"] + assert method(api, "t", "provider")["result"] == "success" def test_get_credential_info(self, app: Flask) -> None: api = ToolBuiltinProviderGetCredentialInfoApi() @@ -321,10 +477,10 @@ def test_get_credential_info(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.get_builtin_tool_provider_credential_info", - return_value={"info": "x"}, + return_value=credential_info_payload(), ), ): - assert method(api, "t", make_account(), "provider") == {"info": "x"} + assert method(api, "t", make_account(), "provider")["credentials"][0]["id"] == "credential-1" def test_get_oauth_client_schema(self, app: Flask) -> None: api = ToolBuiltinProviderGetOauthClientSchemaApi() @@ -334,10 +490,10 @@ def test_get_oauth_client_schema(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.get_builtin_tool_provider_oauth_client_schema", - return_value={"schema": {}}, + return_value=oauth_client_schema_payload(), ), ): - assert method(api, "t", "provider") == {"schema": {}} + assert method(api, "t", "provider")["schema"][0]["name"] == "api_key" class TestApiProviderApis: @@ -354,30 +510,34 @@ def test_add(self, app: Flask) -> None: "schema_type": "openapi", "schema": "{}", "provider": "p", - "icon": empty_mapping(), + "icon": emoji_icon(), } with ( app.test_request_context("/", json=payload), patch( "controllers.console.workspace.tool_providers.ApiToolManageService.create_api_tool_provider", - return_value={"id": 1}, - ), + return_value={"result": "success"}, + ) as create_api_tool_provider, ): - assert method(api, "t", make_account())["id"] == 1 + assert method(api, "t", make_account()) == {"result": "success"} + + create_api_tool_provider.assert_called_once() + assert create_api_tool_provider.call_args.args[3] == emoji_icon() def test_remote_schema(self, app: Flask) -> None: api = ToolApiProviderGetRemoteSchemaApi() method = unwrap(api.get) + openapi_schema = '{"openapi":"3.0.0","info":{"title":"Demo API","version":"1.0.0"},"paths":{}}' with ( app.test_request_context("/?url=http://x.com"), patch( "controllers.console.workspace.tool_providers.ApiToolManageService.get_api_tool_provider_remote_schema", - return_value={"schema": "x"}, + return_value={"schema": openapi_schema}, ), ): - assert method(api, "t", make_account())["schema"] == "x" + assert method(api, "t", make_account()) == {"schema": openapi_schema} def test_list_tools(self, app: Flask) -> None: api = ToolApiProviderListToolsApi() @@ -387,10 +547,10 @@ def test_list_tools(self, app: Flask) -> None: app.test_request_context("/?provider=p"), patch( "controllers.console.workspace.tool_providers.ApiToolManageService.list_api_tool_provider_tools", - return_value=[{"tool": 1}], + return_value=[tool_payload("api_ping")], ), ): - assert method(api, "t", make_account()) == [{"tool": 1}] + assert method(api, "t", make_account())[0]["name"] == "api_ping" def test_update(self, app: Flask) -> None: api = ToolApiProviderUpdateApi() @@ -402,7 +562,7 @@ def test_update(self, app: Flask) -> None: "schema": "{}", "provider": "p", "original_provider": "o", - "icon": empty_mapping(), + "icon": emoji_icon(), "privacy_policy": "", "custom_disclaimer": "", } @@ -411,10 +571,13 @@ def test_update(self, app: Flask) -> None: app.test_request_context("/", json=payload), patch( "controllers.console.workspace.tool_providers.ApiToolManageService.update_api_tool_provider", - return_value={"ok": True}, - ), + return_value={"result": "success"}, + ) as update_api_tool_provider, ): - assert method(api, "t", make_account())["ok"] + assert method(api, "t", make_account()) == {"result": "success"} + + update_api_tool_provider.assert_called_once() + assert update_api_tool_provider.call_args.args[4] == emoji_icon() def test_delete(self, app: Flask) -> None: api = ToolApiProviderDeleteApi() @@ -437,10 +600,10 @@ def test_get(self, app: Flask) -> None: app.test_request_context("/?provider=p"), patch( "controllers.console.workspace.tool_providers.ApiToolManageService.get_api_tool_provider", - return_value={"x": 1}, + return_value=api_provider_detail_payload(), ), ): - assert method(api, "t", make_account()) == {"x": 1} + assert method(api, "t", make_account())["schema"] == "{}" class TestWorkflowApis: @@ -457,7 +620,7 @@ def test_create(self, app: Flask) -> None: "name": "n", "label": "l", "description": "d", - "icon": empty_mapping(), + "icon": emoji_icon(), "parameters": empty_list(), } @@ -465,10 +628,13 @@ def test_create(self, app: Flask) -> None: app.test_request_context("/", json=payload), patch( "controllers.console.workspace.tool_providers.WorkflowToolManageService.create_workflow_tool", - return_value={"id": 1}, - ), + return_value={"result": "success"}, + ) as create_workflow_tool, ): - assert method(api, "t", make_account())["id"] == 1 + assert method(api, "t", make_account()) == {"result": "success"} + + create_workflow_tool.assert_called_once() + assert create_workflow_tool.call_args.kwargs["icon"] == emoji_icon() def test_update_invalid(self, app: Flask) -> None: api = ToolWorkflowProviderUpdateApi() @@ -479,18 +645,21 @@ def test_update_invalid(self, app: Flask) -> None: "name": "Tool", "label": "Tool Label", "description": "A tool", - "icon": empty_mapping(), + "icon": emoji_icon(), } with ( app.test_request_context("/", json=payload), patch( "controllers.console.workspace.tool_providers.WorkflowToolManageService.update_workflow_tool", - return_value={"ok": True}, - ), + return_value={"result": "success"}, + ) as update_workflow_tool, ): result = method(api, "t", make_account()) - assert result["ok"] + assert result == {"result": "success"} + + update_workflow_tool.assert_called_once() + assert update_workflow_tool.call_args.args[5] == emoji_icon() def test_delete(self, app: Flask) -> None: api = ToolWorkflowProviderDeleteApi() @@ -500,10 +669,10 @@ def test_delete(self, app: Flask) -> None: app.test_request_context("/", json={"workflow_tool_id": "123e4567-e89b-12d3-a456-426614174000"}), patch( "controllers.console.workspace.tool_providers.WorkflowToolManageService.delete_workflow_tool", - return_value={"ok": True}, + return_value={"result": "success"}, ), ): - assert method(api, "t", make_account())["ok"] + assert method(api, "t", make_account())["result"] == "success" def test_get_error(self, app: Flask) -> None: api = ToolWorkflowProviderGetApi() @@ -525,49 +694,40 @@ def test_builtin_list(self, app: Flask) -> None: api = ToolBuiltinListApi() method = unwrap(api.get) - m = MagicMock() - m.to_dict.return_value = {"x": 1} - with ( app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.list_builtin_tools", - return_value=[m], + return_value=[provider_entity(provider_id="builtin-1")], ), ): - assert method(api, "t", make_account()) == [{"x": 1}] + assert method(api, "t", make_account())[0]["id"] == "builtin-1" def test_api_list(self, app: Flask) -> None: api = ToolApiListApi() method = unwrap(api.get) - m = MagicMock() - m.to_dict.return_value = {"x": 1} - with ( app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.ApiToolManageService.list_api_tools", - return_value=[m], + return_value=[provider_entity(provider_id="api-1", provider_type="api")], ), ): - assert method(api, "t") == [{"x": 1}] + assert method(api, "t")[0]["id"] == "api-1" def test_workflow_list(self, app: Flask) -> None: api = ToolWorkflowListApi() method = unwrap(api.get) - m = MagicMock() - m.to_dict.return_value = {"x": 1} - with ( app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.WorkflowToolManageService.list_tenant_workflow_tools", - return_value=[m], + return_value=[provider_entity(provider_id="workflow-1", provider_type="workflow")], ), ): - assert method(api, "t", make_account()) == [{"x": 1}] + assert method(api, "t", make_account())[0]["id"] == "workflow-1" class TestLabels: @@ -583,10 +743,10 @@ def test_labels(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.ToolLabelsService.list_tool_labels", - return_value=["l1"], + return_value=[tool_label_payload()], ), ): - assert method(api) == ["l1"] + assert method(api)[0]["name"] == "utilities" class TestOAuth: @@ -630,10 +790,10 @@ def test_save_custom_client(self, app: Flask) -> None: app.test_request_context("/", json={"client_params": {"a": 1}}), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.save_custom_oauth_client_params", - return_value={"ok": True}, + return_value={"result": "success"}, ), ): - assert method(api, "t", "provider")["ok"] + assert method(api, "t", "provider") == {"result": "success"} def test_get_custom_client(self, app: Flask) -> None: api = ToolOAuthCustomClient() @@ -656,7 +816,7 @@ def test_delete_custom_client(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.tool_providers.BuiltinToolManageService.delete_custom_oauth_client_params", - return_value={"ok": True}, + return_value={"result": "success"}, ), ): - assert method(api, "t", "provider")["ok"] + assert method(api, "t", "provider") == {"result": "success"} diff --git a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py index 6684381880c0c0..31d625ac91dc84 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py +++ b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime from inspect import unwrap from unittest.mock import MagicMock, patch @@ -29,6 +30,8 @@ TriggerSubscriptionVerifyApi, ) from core.plugin.entities.plugin_daemon import CredentialType +from core.trigger.entities.api_entities import SubscriptionBuilderApiEntity, TriggerProviderApiEntity +from core.trigger.entities.entities import RequestLog from models.account import Account @@ -38,6 +41,47 @@ def mock_user() -> Account: return user +def trigger_provider() -> TriggerProviderApiEntity: + return TriggerProviderApiEntity( + author="Dify", + name="github", + label={"en_US": "GitHub"}, + description={"en_US": "GitHub trigger provider"}, + icon="icon.svg", + icon_dark=None, + tags=["code"], + plugin_id="plugin", + plugin_unique_identifier="plugin:github", + supported_creation_methods=[], + subscription_constructor=None, + subscription_schema=[], + events=[], + ) + + +def subscription_builder() -> SubscriptionBuilderApiEntity: + return SubscriptionBuilderApiEntity( + id="b1", + name="Builder", + provider="github", + endpoint="b1", + parameters={"repo": "dify"}, + properties={"branch": "main"}, + credentials={"token": "secret"}, + credential_type=CredentialType.UNAUTHORIZED, + ) + + +def request_log() -> RequestLog: + return RequestLog( + id="log1", + endpoint="/hooks/b1", + request={"headers": {}, "body": {"event": "push"}}, + response={"status": 200, "body": {"ok": True}}, + created_at=datetime(2024, 1, 1), + ) + + class TestTriggerProviderApis: @pytest.fixture def app(self, flask_app_with_containers: Flask) -> Flask: @@ -77,10 +121,10 @@ def test_provider_info(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.get_trigger_provider", - return_value={"id": "p1"}, + return_value=trigger_provider(), ), ): - assert method(api, "t1", "github") == {"id": "p1"} + assert method(api, "t1", "github")["name"] == "github" class TestTriggerSubscriptionListApi: @@ -129,11 +173,11 @@ def test_create_builder(self, app: Flask) -> None: app.test_request_context("/", json={"credential_type": "UNAUTHORIZED"}), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.create_trigger_subscription_builder", - return_value={"id": "b1"}, + return_value=subscription_builder(), ), ): result = method(api, "t1", mock_user(), "github") - assert "subscription_builder" in result + assert result["subscription_builder"]["id"] == "b1" def test_get_builder(self, app: Flask) -> None: api = TriggerSubscriptionBuilderGetApi() @@ -143,10 +187,10 @@ def test_get_builder(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.get_subscription_builder_by_id", - return_value={"id": "b1"}, + return_value=subscription_builder(), ), ): - assert method(api, "github", "b1") == {"id": "b1"} + assert method(api, "github", "b1")["id"] == "b1" def test_verify_builder(self, app: Flask) -> None: api = TriggerSubscriptionBuilderVerifyApi() @@ -156,10 +200,10 @@ def test_verify_builder(self, app: Flask) -> None: app.test_request_context("/", json={"credentials": {"a": 1}}), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.update_and_verify_builder", - return_value={"ok": True}, + return_value={"verified": True}, ), ): - assert method(api, "t1", mock_user(), "github", "b1") == {"ok": True} + assert method(api, "t1", mock_user(), "github", "b1") == {"verified": True} def test_verify_builder_error(self, app: Flask) -> None: api = TriggerSubscriptionBuilderVerifyApi() @@ -183,26 +227,24 @@ def test_update_builder(self, app: Flask) -> None: app.test_request_context("/", json={"name": "n"}), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.update_trigger_subscription_builder", - return_value={"id": "b1"}, + return_value=subscription_builder(), ), ): - assert method(api, "t1", "github", "b1") == {"id": "b1"} + assert method(api, "t1", "github", "b1")["id"] == "b1" def test_logs(self, app: Flask) -> None: api = TriggerSubscriptionBuilderLogsApi() method = unwrap(api.get) - log = MagicMock() - log.model_dump.return_value = {"a": 1} - with ( app.test_request_context("/"), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.list_logs", - return_value=[log], + return_value=[request_log()], ), ): - assert "logs" in method(api, "github", "b1") + result = method(api, "github", "b1") + assert result["logs"][0]["id"] == "log1" def test_build(self, app: Flask) -> None: api = TriggerSubscriptionBuilderBuildApi() @@ -215,7 +257,7 @@ def test_build(self, app: Flask) -> None: return_value=None, ), ): - assert method(api, "t1", mock_user(), "github", "b1") == 200 + assert method(api, "t1", mock_user(), "github", "b1") == {"result": "success"} class TestTriggerSubscriptionCrud: @@ -239,7 +281,7 @@ def test_update_rename_only(self, app: Flask) -> None: ), patch("controllers.console.workspace.trigger_providers.TriggerProviderService.update_trigger_subscription"), ): - assert method(api, "t1", "s1") == 200 + assert method(api, "t1", "s1") == {"result": "success"} def test_update_not_found(self, app: Flask) -> None: api = TriggerSubscriptionUpdateApi() @@ -275,7 +317,7 @@ def test_update_rebuild(self, app: Flask) -> None: "controllers.console.workspace.trigger_providers.TriggerProviderService.rebuild_trigger_subscription" ), ): - assert method(api, "t1", "s1") == 200 + assert method(api, "t1", "s1") == {"result": "success"} def test_delete_subscription(self, app: Flask) -> None: api = TriggerSubscriptionDeleteApi() @@ -336,7 +378,7 @@ def test_oauth_authorize_success(self, app: Flask) -> None: ), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.create_trigger_subscription_builder", - return_value=MagicMock(id="b1"), + return_value=subscription_builder(), ), patch( "controllers.console.workspace.trigger_providers.OAuthProxyService.create_proxy_context", @@ -480,7 +522,7 @@ def test_get_client(self, app: Flask) -> None: ), patch( "controllers.console.workspace.trigger_providers.TriggerManager.get_trigger_provider", - return_value=MagicMock(get_oauth_client_schema=lambda: {}), + return_value=MagicMock(get_oauth_client_schema=lambda: []), ), ): result = method(api, "t1", "github") @@ -494,10 +536,10 @@ def test_post_client(self, app: Flask) -> None: app.test_request_context("/", json={"enabled": True}), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.save_custom_oauth_client_params", - return_value={"ok": True}, + return_value={"result": "success"}, ), ): - assert method(api, "t1", "github") == {"ok": True} + assert method(api, "t1", "github") == {"result": "success"} def test_delete_client(self, app: Flask) -> None: api = TriggerOAuthClientManageApi() @@ -507,10 +549,10 @@ def test_delete_client(self, app: Flask) -> None: app.test_request_context("/"), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.delete_custom_oauth_client_params", - return_value={"ok": True}, + return_value={"result": "success"}, ), ): - assert method(api, "t1", "github") == {"ok": True} + assert method(api, "t1", "github") == {"result": "success"} def test_oauth_client_post_value_error(self, app: Flask) -> None: api = TriggerOAuthClientManageApi() @@ -540,10 +582,10 @@ def test_verify_success(self, app: Flask) -> None: app.test_request_context("/", json={"credentials": {}}), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.verify_subscription_credentials", - return_value={"ok": True}, + return_value={"verified": True}, ), ): - assert method(api, "t1", mock_user(), "github", "s1") == {"ok": True} + assert method(api, "t1", mock_user(), "github", "s1") == {"verified": True} @pytest.mark.parametrize("raised_exception", [ValueError("bad"), Exception("boom")]) def test_verify_errors(self, app: Flask, raised_exception: Exception) -> None: diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_site.py b/api/tests/test_containers_integration_tests/controllers/web/test_site.py index 9adb26ff3d238c..4fc99cdc74c76a 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_site.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_site.py @@ -2,21 +2,22 @@ from __future__ import annotations -from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from flask import Flask from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden -from controllers.web.site import AppSiteApi, AppSiteInfo +from controllers.web.site import AppSiteApi, WebAppSiteResponse, WebModelConfigResponse from models import Tenant, TenantStatus -from models.model import App, AppMode, CustomizeTokenStrategy, Site +from models.account import TenantCustomConfigDict +from models.model import App, AppMode, AppModelConfig, CustomizeTokenStrategy, EndUser, Site +from services.feature_service import FeatureModel @pytest.fixture -def app(flask_app_with_containers) -> Flask: +def app(flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers @@ -41,7 +42,23 @@ def _create_app(db_session: Session, tenant_id: str, *, enable_site: bool = True def _create_site(db_session: Session, app_id: str) -> Site: - site = Site( + site = _site_model(app_id=app_id) + db_session.add(site) + db_session.commit() + return site + + +def _end_user(tenant_id: str, app_id: str) -> EndUser: + return EndUser( + tenant_id=tenant_id, + app_id=app_id, + type="browser", + session_id=f"session-{app_id}", + ) + + +def _site_model(*, app_id: str) -> Site: + return Site( app_id=app_id, title="Site", icon_type="emoji", @@ -51,31 +68,30 @@ def _create_site(db_session: Session, app_id: str) -> Site: default_language="en", chat_color_theme="light", chat_color_theme_inverted=False, + custom_disclaimer="", customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, code=f"code-{app_id[-6:]}", prompt_public=False, show_workflow_steps=True, use_icon_as_answer_icon=False, ) - db_session.add(site) - db_session.commit() - return site class TestAppSiteApi: @patch("controllers.web.site.FeatureService.get_features") - def test_happy_path(self, mock_features, app: Flask, db_session_with_containers: Session) -> None: + def test_happy_path(self, mock_features: MagicMock, app: Flask, db_session_with_containers: Session) -> None: app.config["RESTX_MASK_HEADER"] = "X-Fields" tenant = _create_tenant(db_session_with_containers) app_model = _create_app(db_session_with_containers, tenant.id) _create_site(db_session_with_containers, app_model.id) - end_user = SimpleNamespace(id="eu-1") - mock_features.return_value = SimpleNamespace(can_replace_logo=False) + end_user = _end_user(tenant.id, app_model.id) + mock_features.return_value = FeatureModel(can_replace_logo=False) with app.test_request_context("/site"): result = AppSiteApi().get(app_model, end_user) assert result["app_id"] == app_model.id + assert result["end_user_id"] == end_user.id assert result["plan"] == "basic" assert result["enable_site"] is True @@ -83,51 +99,139 @@ def test_missing_site_raises_forbidden(self, app: Flask, db_session_with_contain app.config["RESTX_MASK_HEADER"] = "X-Fields" tenant = _create_tenant(db_session_with_containers) app_model = _create_app(db_session_with_containers, tenant.id) - end_user = SimpleNamespace(id="eu-1") + end_user = _end_user(tenant.id, app_model.id) with app.test_request_context("/site"): with pytest.raises(Forbidden): AppSiteApi().get(app_model, end_user) - @patch("controllers.web.site.FeatureService.get_features") - def test_archived_tenant_raises_forbidden( - self, mock_features, app: Flask, db_session_with_containers: Session - ) -> None: + def test_archived_tenant_raises_forbidden(self, app: Flask, db_session_with_containers: Session) -> None: app.config["RESTX_MASK_HEADER"] = "X-Fields" tenant = _create_tenant(db_session_with_containers, status=TenantStatus.ARCHIVE) app_model = _create_app(db_session_with_containers, tenant.id) _create_site(db_session_with_containers, app_model.id) - end_user = SimpleNamespace(id="eu-1") - mock_features.return_value = SimpleNamespace(can_replace_logo=False) + end_user = _end_user(tenant.id, app_model.id) with app.test_request_context("/site"): with pytest.raises(Forbidden): AppSiteApi().get(app_model, end_user) -class TestAppSiteInfo: +def _tenant_model(*, plan: str = "basic", custom_config: TenantCustomConfigDict | None = None) -> Tenant: + tenant = Tenant(name="test-tenant", plan=plan) + tenant.custom_config_dict = custom_config or {} + return tenant + + +def _app_model(*, tenant: Tenant, enable_site: bool = True) -> App: + app_model = App( + tenant_id=tenant.id, + mode=AppMode.CHAT, + name="test-app", + enable_site=enable_site, + enable_api=True, + ) + app_model.id = "app-test" + return app_model + + +class TestWebAppSiteResponse: def test_basic_fields(self) -> None: - tenant = SimpleNamespace(id="tenant-1", plan="basic", custom_config_dict={}) - site_obj = SimpleNamespace() - info = AppSiteInfo(tenant, SimpleNamespace(id="app-1", enable_site=True), site_obj, "eu-1", False) - - assert info.app_id == "app-1" - assert info.end_user_id == "eu-1" - assert info.enable_site is True - assert info.plan == "basic" - assert info.can_replace_logo is False - assert info.model_config is None - - @patch("controllers.web.site.dify_config", SimpleNamespace(FILES_URL="https://files.example.com")) + tenant = _tenant_model() + app_model = _app_model(tenant=tenant) + response = WebAppSiteResponse.from_app_site( + tenant=tenant, + app_model=app_model, + site=_site_model(app_id=app_model.id), + end_user_id="eu-1", + can_replace_logo=False, + ) + + assert response.app_id == app_model.id + assert response.end_user_id == "eu-1" + assert response.enable_site is True + assert response.plan == "basic" + assert response.can_replace_logo is False + assert response.model_config_ is None + assert response.custom_config is None + assert response.site.custom_disclaimer == "" + + def test_nullable_site_fields_preserve_none(self) -> None: + tenant = _tenant_model() + app_model = _app_model(tenant=tenant) + site = _site_model(app_id=app_model.id) + site.chat_color_theme = None + site.icon_type = None + site.icon = None + site.icon_background = None + site.description = None + site.copyright = None + site.privacy_policy = None + + response = WebAppSiteResponse.from_app_site( + tenant=tenant, + app_model=app_model, + site=site, + end_user_id=None, + can_replace_logo=False, + ) + + dumped = response.model_dump(mode="json") + assert dumped["end_user_id"] is None + assert dumped["site"]["chat_color_theme"] is None + assert dumped["site"]["icon_type"] is None + assert dumped["site"]["icon"] is None + assert dumped["site"]["icon_background"] is None + assert dumped["site"]["description"] is None + assert dumped["site"]["copyright"] is None + assert dumped["site"]["privacy_policy"] is None + assert dumped["site"]["custom_disclaimer"] == "" + + @patch("controllers.web.site.dify_config.FILES_URL", "https://files.example.com") def test_can_replace_logo_sets_custom_config(self) -> None: - tenant = SimpleNamespace( - id="tenant-1", + tenant = _tenant_model( plan="pro", - custom_config_dict={"remove_webapp_brand": True, "replace_webapp_logo": True}, + custom_config={"remove_webapp_brand": True, "replace_webapp_logo": "enabled"}, + ) + app_model = _app_model(tenant=tenant) + response = WebAppSiteResponse.from_app_site( + tenant=tenant, + app_model=app_model, + site=_site_model(app_id=app_model.id), + end_user_id="eu-1", + can_replace_logo=True, + ) + + assert response.can_replace_logo is True + assert response.custom_config is not None + assert response.custom_config.remove_webapp_brand is True + assert response.custom_config.replace_webapp_logo is not None + assert "webapp-logo" in response.custom_config.replace_webapp_logo + + +class TestWebModelConfigResponse: + def test_serializes_internal_model_config_properties_to_public_keys(self) -> None: + model_config = AppModelConfig( + app_id="app-test", + opening_statement="Hello", + suggested_questions='["Question?"]', + suggested_questions_after_answer='{"enabled": true}', + more_like_this='{"enabled": false}', + model='{"provider": "openai", "name": "gpt-4o", "mode": "chat"}', + user_input_form='[{"text-input": {"label": "Name", "variable": "name", "required": true}}]', + pre_prompt="System prompt", + created_by="account-1", + updated_by="account-1", ) - site_obj = SimpleNamespace() - info = AppSiteInfo(tenant, SimpleNamespace(id="app-1", enable_site=True), site_obj, "eu-1", True) - assert info.can_replace_logo is True - assert info.custom_config["remove_webapp_brand"] is True - assert "webapp-logo" in info.custom_config["replace_webapp_logo"] + dumped = WebModelConfigResponse.model_validate(model_config, from_attributes=True).model_dump(mode="json") + + assert dumped == { + "opening_statement": "Hello", + "suggested_questions": ["Question?"], + "suggested_questions_after_answer": {"enabled": True}, + "more_like_this": {"enabled": False}, + "model": {"provider": "openai", "name": "gpt-4o", "mode": "chat"}, + "user_input_form": [{"text-input": {"label": "Name", "variable": "name", "required": True}}], + "pre_prompt": "System prompt", + } diff --git a/api/tests/test_containers_integration_tests/services/tools/test_api_tools_manage_service.py b/api/tests/test_containers_integration_tests/services/tools/test_api_tools_manage_service.py index af83adaae01a61..28ae8e5935e7d8 100644 --- a/api/tests/test_containers_integration_tests/services/tools/test_api_tools_manage_service.py +++ b/api/tests/test_containers_integration_tests/services/tools/test_api_tools_manage_service.py @@ -1,6 +1,7 @@ import inspect import json -from unittest.mock import patch +from collections.abc import Iterator +from unittest.mock import MagicMock, patch import pytest from faker import Faker @@ -10,16 +11,18 @@ from core.tools.entities.tool_entities import ApiProviderSchemaType from core.tools.errors import ApiToolProviderNotFoundError from core.tools.tool_label_manager import ToolLabelManager -from models import Account, Tenant +from models import Account, AccountStatus, Tenant, TenantStatus from models.tools import ApiToolProvider from services.tools.api_tools_manage_service import ApiToolManageService +MockDependencies = dict[str, MagicMock] + class TestApiToolManageService: """Integration tests for ApiToolManageService using testcontainers.""" @pytest.fixture - def mock_external_service_dependencies(self): + def mock_external_service_dependencies(self) -> Iterator[MockDependencies]: """Mock setup for external service dependencies.""" with ( patch("services.tools.api_tools_manage_service.ToolLabelManager") as mock_tool_label_manager, @@ -39,7 +42,9 @@ def mock_external_service_dependencies(self): "provider_controller": mock_provider_controller, } - def _create_test_account_and_tenant(self, db_session_with_containers: Session, mock_external_service_dependencies): + def _create_test_account_and_tenant( + self, db_session_with_containers: Session, mock_external_service_dependencies: MockDependencies + ) -> tuple[Account, Tenant]: """ Helper method to create a test account and tenant for testing. @@ -57,7 +62,7 @@ def _create_test_account_and_tenant(self, db_session_with_containers: Session, m email=fake.email(), name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) @@ -66,7 +71,7 @@ def _create_test_account_and_tenant(self, db_session_with_containers: Session, m # Create tenant for the account tenant = Tenant( name=fake.company(), - status="normal", + status=TenantStatus.NORMAL, ) db_session_with_containers.add(tenant) db_session_with_containers.commit() @@ -88,7 +93,7 @@ def _create_test_account_and_tenant(self, db_session_with_containers: Session, m return account, tenant - def _create_test_openapi_schema(self): + def _create_test_openapi_schema(self) -> str: """Helper method to create a test OpenAPI schema.""" return """ { @@ -121,8 +126,11 @@ def _create_test_openapi_schema(self): """ def test_parser_api_schema_success( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test successful parsing of API schema. @@ -148,6 +156,8 @@ def test_parser_api_schema_success( # Verify credentials schema structure credentials_schema = result["credentials_schema"] assert len(credentials_schema) == 3 + assert all(isinstance(field, dict) for field in credentials_schema) + assert all(isinstance(tool, dict) for tool in result["parameters_schema"]) # Check auth_type field auth_type_field = next(field for field in credentials_schema if field["name"] == "auth_type") @@ -166,8 +176,11 @@ def test_parser_api_schema_success( assert api_key_value_field["default"] == "" def test_parser_api_schema_invalid_schema( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test parsing of invalid API schema. @@ -186,8 +199,11 @@ def test_parser_api_schema_invalid_schema( assert "invalid schema" in str(exc_info.value) def test_parser_api_schema_malformed_json( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test parsing of malformed JSON schema. @@ -206,8 +222,11 @@ def test_parser_api_schema_malformed_json( assert "invalid schema" in str(exc_info.value) def test_convert_schema_to_tool_bundles_success( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test successful conversion of schema to tool bundles. @@ -236,8 +255,11 @@ def test_convert_schema_to_tool_bundles_success( assert tool_bundle.operation_id == "testOperation" def test_convert_schema_to_tool_bundles_with_extra_info( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test successful conversion of schema to tool bundles with extra info. @@ -262,8 +284,11 @@ def test_convert_schema_to_tool_bundles_with_extra_info( assert isinstance(schema_type, str) def test_convert_schema_to_tool_bundles_invalid_schema( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test conversion of invalid schema to tool bundles. @@ -282,8 +307,11 @@ def test_convert_schema_to_tool_bundles_invalid_schema( assert "invalid schema" in str(exc_info.value) def test_create_api_tool_provider_success( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test successful creation of API tool provider. @@ -301,7 +329,7 @@ def test_create_api_tool_provider_success( ) provider_name = fake.company() - icon = {"type": "emoji", "value": "🔧"} + icon = {"content": "🔧", "background": "#FFF"} credentials = {"auth_type": "none", "api_key_header": "X-API-Key", "api_key_value": ""} schema_type = ApiProviderSchemaType.OPENAPI schema = self._create_test_openapi_schema() @@ -341,6 +369,7 @@ def test_create_api_tool_provider_success( assert provider.schema_type_str == schema_type assert provider.privacy_policy == privacy_policy assert provider.custom_disclaimer == custom_disclaimer + assert json.loads(provider.icon) == icon # Verify mock interactions mock_external_service_dependencies["tool_label_manager"].update_tool_labels.assert_called_once() @@ -349,8 +378,11 @@ def test_create_api_tool_provider_success( mock_external_service_dependencies["provider_controller"].load_bundled_tools.assert_called_once() def test_create_api_tool_provider_duplicate_name( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test creation of API tool provider with duplicate name. @@ -366,7 +398,7 @@ def test_create_api_tool_provider_duplicate_name( ) provider_name = fake.company() - icon = {"type": "emoji", "value": "🔧"} + icon = {"content": "🔧", "background": "#FFF"} credentials = {"auth_type": "none"} schema_type = ApiProviderSchemaType.OPENAPI schema = self._create_test_openapi_schema() @@ -406,8 +438,11 @@ def test_create_api_tool_provider_duplicate_name( assert f"provider {provider_name} already exists" in str(exc_info.value) def test_create_api_tool_provider_invalid_schema_type( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test creation of API tool provider with invalid schema type. @@ -423,7 +458,7 @@ def test_create_api_tool_provider_invalid_schema_type( ) provider_name = fake.company() - icon = {"type": "emoji", "value": "🔧"} + icon = {"content": "🔧", "background": "#FFF"} credentials = {"auth_type": "none"} schema_type = "invalid_type" schema = self._create_test_openapi_schema() @@ -438,8 +473,11 @@ def test_create_api_tool_provider_invalid_schema_type( assert "validation error" in str(exc_info.value) def test_create_api_tool_provider_missing_auth_type( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test creation of API tool provider with missing auth type. @@ -455,7 +493,7 @@ def test_create_api_tool_provider_missing_auth_type( ) provider_name = fake.company() - icon = {"type": "emoji", "value": "🔧"} + icon = {"content": "🔧", "background": "#FFF"} credentials = {} # Missing auth_type schema_type = ApiProviderSchemaType.OPENAPI schema = self._create_test_openapi_schema() @@ -481,8 +519,11 @@ def test_create_api_tool_provider_missing_auth_type( assert "auth_type is required" in str(exc_info.value) def test_create_api_tool_provider_with_api_key_auth( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test successful creation of API tool provider with API key authentication. @@ -498,7 +539,7 @@ def test_create_api_tool_provider_with_api_key_auth( ) provider_name = fake.company() - icon = {"type": "emoji", "value": "🔑"} + icon = {"content": "🔑", "background": "#FFF"} credentials = {"auth_type": "api_key", "api_key_header": "X-API-Key", "api_key_value": fake.uuid4()} schema_type = ApiProviderSchemaType.OPENAPI schema = self._create_test_openapi_schema() @@ -542,8 +583,11 @@ def test_create_api_tool_provider_with_api_key_auth( mock_external_service_dependencies["provider_controller"].from_db.assert_called_once() def test_delete_api_tool_provider_success( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """Test successful deletion of an API tool provider.""" fake = Faker() account, tenant = self._create_test_account_and_tenant( @@ -583,8 +627,8 @@ def test_delete_api_tool_provider_success( assert deleted is None def test_delete_api_tool_provider_not_found( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MockDependencies + ) -> None: """Test deletion raises ValueError when provider not found.""" fake = Faker() account, tenant = self._create_test_account_and_tenant( @@ -595,14 +639,15 @@ def test_delete_api_tool_provider_not_found( ApiToolManageService.delete_api_tool_provider(account.id, tenant.id, "nonexistent") def test_update_api_tool_provider_success( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: fake = Faker() # Firmware fix for cache.delete() in update flow mock_encrypter = mock_external_service_dependencies["encrypter"] - from unittest.mock import MagicMock - mock_cache = MagicMock() mock_cache.delete.return_value = None mock_encrypter.return_value = (mock_encrypter, mock_cache) @@ -620,7 +665,7 @@ def test_update_api_tool_provider_success( user_id=account.id, tenant_id=tenant.id, provider_name=original_name, - icon={"type": "emoji", "value": "🔧"}, + icon={"content": "🔧", "background": "#FFF"}, credentials={"auth_type": "none"}, schema_type=ApiProviderSchemaType.OPENAPI, schema=self._create_test_openapi_schema(), @@ -646,7 +691,7 @@ def test_update_api_tool_provider_success( provider_name=new_name, original_provider=original_name, # new icon - changed 2 - icon={"type": "emoji", "value": "🚀"}, + icon={"content": "🚀", "background": "#FFF"}, credentials={"auth_type": "none"}, _schema_type=ApiProviderSchemaType.OPENAPI, schema=self._create_test_openapi_schema(), @@ -677,9 +722,7 @@ def test_update_api_tool_provider_success( # - changed 1 assert updated_provider.name == new_name # - changed 2 - icon_data = json.loads(updated_provider.icon) - assert icon_data["type"] == "emoji" - assert icon_data["value"] == "🚀" + assert json.loads(updated_provider.icon) == {"content": "🚀", "background": "#FFF"} # - changed 3 assert updated_provider.privacy_policy == "https://new-policy.com" # - changed 4 @@ -712,8 +755,11 @@ def test_update_api_tool_provider_success( ) def test_update_api_tool_provider_not_found( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """ Test update raises ValueError when original provider not found. @@ -733,7 +779,7 @@ def test_update_api_tool_provider_not_found( user_id=account.id, tenant_id=tenant.id, provider_name=existing_provider_name, - icon={"type": "emoji", "value": "🔧"}, + icon={"content": "🔧", "background": "#FFF"}, credentials={"auth_type": "none"}, schema_type=ApiProviderSchemaType.OPENAPI, schema=self._create_test_openapi_schema(), @@ -756,7 +802,7 @@ def test_update_api_tool_provider_not_found( tenant_id=tenant.id, provider_name=target_new_name, original_provider=missing_original_name, - icon={"type": "emoji", "value": "🚀"}, + icon={"content": "🚀", "background": "#FFF"}, credentials={"auth_type": "none"}, _schema_type=ApiProviderSchemaType.OPENAPI, schema=self._create_test_openapi_schema(), @@ -793,8 +839,11 @@ def test_update_api_tool_provider_not_found( mock_external_service_dependencies["provider_controller"].from_db.assert_not_called() def test_update_api_tool_provider_missing_auth_type( - self, flask_req_ctx_with_containers, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, + flask_req_ctx_with_containers: object, + db_session_with_containers: Session, + mock_external_service_dependencies: MockDependencies, + ) -> None: """Test update raises ValueError when auth_type is missing from credentials.""" fake = Faker() account, tenant = self._create_test_account_and_tenant( @@ -822,7 +871,7 @@ def test_update_api_tool_provider_missing_auth_type( tenant_id=tenant.id, provider_name=provider_name, original_provider=provider_name, - icon={}, + icon={"content": "🔧", "background": "#FFF"}, credentials={}, _schema_type=ApiProviderSchemaType.OPENAPI, schema=schema, @@ -832,8 +881,8 @@ def test_update_api_tool_provider_missing_auth_type( ) def test_list_api_tool_provider_tools_not_found( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MockDependencies + ) -> None: """Test listing tools raises ValueError when provider not found.""" fake = Faker() account, tenant = self._create_test_account_and_tenant( @@ -844,8 +893,8 @@ def test_list_api_tool_provider_tools_not_found( ApiToolManageService.list_api_tool_provider_tools(account.id, tenant.id, "nonexistent") def test_test_api_tool_preview_invalid_schema_type( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MockDependencies + ) -> None: """Test preview raises ValueError for invalid schema type.""" fake = Faker() account, tenant = self._create_test_account_and_tenant( diff --git a/api/tests/unit_tests/commands/test_lint_response_contracts.py b/api/tests/unit_tests/commands/test_lint_response_contracts.py index 351fdf0d92301c..68c4cbf966e933 100644 --- a/api/tests/unit_tests/commands/test_lint_response_contracts.py +++ b/api/tests/unit_tests/commands/test_lint_response_contracts.py @@ -77,6 +77,25 @@ def post(self): assert "prefer dump_response" in checks[0].reason +def test_constructor_variable_model_dump_is_valid(tmp_path: Path): + checks = _checks_for_source( + tmp_path, + """ +@ns.route("/annotations") +class AnnotationApi(Resource): + @ns.response(201, "Created", ns.models[AnnotationResponse.__name__]) + def post(self): + response = AnnotationResponse(id="new", name=name) + return response.model_dump(mode="json"), 201 +""", + ) + + assert len(checks) == 1 + assert checks[0].classification == "valid" + assert checks[0].actual[0].kind == "model" + assert checks[0].actual[0].model == "AnnotationResponse" + + def test_variable_model_dump_with_wrong_documented_schema_is_mismatch(tmp_path: Path): checks = _checks_for_source( tmp_path, @@ -117,6 +136,38 @@ def generate_events(): assert {actual.model for actual in checks[0].actual} == {"StreamResponse"} +def test_response_contract_ignore_comment_skips_route_method(tmp_path: Path): + checks = _checks_for_source( + tmp_path, + """ +@ns.route("/binary") +class BinaryApi(Resource): + # response-contract:ignore binary response + @ns.response(200, "Binary file") + def get(self): + return send_file(path) + + +# response-contract:ignore compact Flask response +@ns.route("/compact") +class CompactApi(Resource): + def get(self): + return make_response({"url": "https://example.com"}) + + +@ns.route("/regular") +class RegularApi(Resource): + @ns.response(200, "OK", ns.models[RegularResponse.__name__]) + def get(self): + return dump_response(RegularResponse, {}) +""", + ) + + assert len(checks) == 1 + assert checks[0].class_name == "RegularApi" + assert checks[0].classification == "valid" + + def test_main_is_report_only_by_default_for_mismatches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): module = _load_lint_response_contracts_module() controller_path = tmp_path / "controllers" / "sample.py" diff --git a/api/tests/unit_tests/controllers/console/app/test_audio.py b/api/tests/unit_tests/controllers/console/app/test_audio.py index 82b9b68247f7d5..1411a336482ca4 100644 --- a/api/tests/unit_tests/controllers/console/app/test_audio.py +++ b/api/tests/unit_tests/controllers/console/app/test_audio.py @@ -120,7 +120,8 @@ def test_console_text_api_error_mapping(app: Flask, monkeypatch: pytest.MonkeyPa def test_console_text_modes_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(AudioService, "transcript_tts_voices", lambda **_kwargs: ["voice-1"]) + expected_voices = [{"name": "Voice 1", "value": "voice-1"}] + monkeypatch.setattr(AudioService, "transcript_tts_voices", lambda **_kwargs: expected_voices) api = TextModesApi() handler = unwrap(api.get) @@ -129,7 +130,7 @@ def test_console_text_modes_success(app: Flask, monkeypatch: pytest.MonkeyPatch) with app.test_request_context("/console/api/apps/app/text-to-audio/voices?language=en", method="GET"): response = handler(api, app_model=app_model) - assert response == ["voice-1"] + assert response == expected_voices def test_console_text_modes_language_error(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -214,7 +215,8 @@ def test_text_to_audio_voices_success(app: Flask, monkeypatch: pytest.MonkeyPatc api = TextModesApi() method = unwrap(api.get) - monkeypatch.setattr(AudioService, "transcript_tts_voices", lambda **_kwargs: ["voice-1"]) + expected_voices = [{"name": "Voice 1", "value": "voice-1"}] + monkeypatch.setattr(AudioService, "transcript_tts_voices", lambda **_kwargs: expected_voices) app_model = SimpleNamespace(tenant_id="tenant-1") @@ -225,7 +227,7 @@ def test_text_to_audio_voices_success(app: Flask, monkeypatch: pytest.MonkeyPatc ): response = method(api, app_model=app_model) - assert response == ["voice-1"] + assert response == expected_voices def test_audio_to_text_with_invalid_file(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -272,7 +274,7 @@ def test_text_to_audio_voices_with_language_filter(app: Flask, monkeypatch: pyte monkeypatch.setattr( AudioService, "transcript_tts_voices", - lambda **_kwargs: [{"id": "voice-1", "name": "Voice 1"}], + lambda **_kwargs: [{"name": "Voice 1", "value": "voice-1"}], ) app_model = SimpleNamespace(tenant_id="tenant-1") diff --git a/api/tests/unit_tests/controllers/console/app/test_audio_api.py b/api/tests/unit_tests/controllers/console/app/test_audio_api.py deleted file mode 100644 index 40e6be114178f9..00000000000000 --- a/api/tests/unit_tests/controllers/console/app/test_audio_api.py +++ /dev/null @@ -1,149 +0,0 @@ -from __future__ import annotations - -import io -from inspect import unwrap -from types import SimpleNamespace - -import pytest -from flask import Flask - -from controllers.console.app import audio as audio_module -from controllers.console.app.error import AudioTooLargeError -from services.errors.audio import AudioTooLargeServiceError - - -def test_audio_to_text_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - api = audio_module.ChatMessageAudioApi() - method = unwrap(api.post) - - response_payload = {"text": "hello"} - monkeypatch.setattr(audio_module.AudioService, "transcript_asr", lambda **_kwargs: response_payload) - - app_model = SimpleNamespace(id="app-1") - - data = {"file": (io.BytesIO(b"x"), "sample.wav")} - with app.test_request_context( - "/console/api/apps/app-1/audio-to-text", - method="POST", - data=data, - content_type="multipart/form-data", - ): - response = method(api, app_model=app_model) - - assert response == response_payload - - -def test_audio_to_text_maps_audio_too_large(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - api = audio_module.ChatMessageAudioApi() - method = unwrap(api.post) - - monkeypatch.setattr( - audio_module.AudioService, - "transcript_asr", - lambda **_kwargs: (_ for _ in ()).throw(AudioTooLargeServiceError("too large")), - ) - - app_model = SimpleNamespace(id="app-1") - - data = {"file": (io.BytesIO(b"x"), "sample.wav")} - with app.test_request_context( - "/console/api/apps/app-1/audio-to-text", - method="POST", - data=data, - content_type="multipart/form-data", - ): - with pytest.raises(AudioTooLargeError): - method(api, app_model=app_model) - - -def test_text_to_audio_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - api = audio_module.ChatMessageTextApi() - method = unwrap(api.post) - - monkeypatch.setattr(audio_module.AudioService, "transcript_tts", lambda **_kwargs: {"audio": "ok"}) - - app_model = SimpleNamespace(id="app-1") - - with app.test_request_context( - "/console/api/apps/app-1/text-to-audio", - method="POST", - json={"text": "hello"}, - ): - response = method(api, app_model=app_model) - - assert response == {"audio": "ok"} - - -def test_text_to_audio_voices_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - api = audio_module.TextModesApi() - method = unwrap(api.get) - - monkeypatch.setattr(audio_module.AudioService, "transcript_tts_voices", lambda **_kwargs: ["voice-1"]) - - app_model = SimpleNamespace(tenant_id="tenant-1") - - with app.test_request_context( - "/console/api/apps/app-1/text-to-audio/voices", - method="GET", - query_string={"language": "en-US"}, - ): - response = method(api, app_model=app_model) - - assert response == ["voice-1"] - - -def test_audio_to_text_with_invalid_file(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - api = audio_module.ChatMessageAudioApi() - method = unwrap(api.post) - - monkeypatch.setattr(audio_module.AudioService, "transcript_asr", lambda **_kwargs: {"text": "test"}) - - app_model = SimpleNamespace(id="app-1") - - data = {"file": (io.BytesIO(b"invalid"), "sample.xyz")} - with app.test_request_context( - "/console/api/apps/app-1/audio-to-text", - method="POST", - data=data, - content_type="multipart/form-data", - ): - # Should not raise, AudioService is mocked - response = method(api, app_model=app_model) - assert response == {"text": "test"} - - -def test_text_to_audio_with_language_param(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - api = audio_module.ChatMessageTextApi() - method = unwrap(api.post) - - monkeypatch.setattr(audio_module.AudioService, "transcript_tts", lambda **_kwargs: {"audio": "test"}) - - app_model = SimpleNamespace(id="app-1") - - with app.test_request_context( - "/console/api/apps/app-1/text-to-audio", - method="POST", - json={"text": "hello", "language": "en-US"}, - ): - response = method(api, app_model=app_model) - assert response == {"audio": "test"} - - -def test_text_to_audio_voices_with_language_filter(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - api = audio_module.TextModesApi() - method = unwrap(api.get) - - monkeypatch.setattr( - audio_module.AudioService, - "transcript_tts_voices", - lambda **_kwargs: [{"id": "voice-1", "name": "Voice 1"}], - ) - - app_model = SimpleNamespace(tenant_id="tenant-1") - - with app.test_request_context( - "/console/api/apps/app-1/text-to-audio/voices?language=en-US", - method="GET", - ): - response = method(api, app_model=app_model) - assert isinstance(response, list) diff --git a/api/tests/unit_tests/controllers/console/app/test_message_api.py b/api/tests/unit_tests/controllers/console/app/test_message_api.py index 067edc6fd68f0f..20187da6159297 100644 --- a/api/tests/unit_tests/controllers/console/app/test_message_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_message_api.py @@ -125,13 +125,57 @@ def test_message_detail_response_normalizes_aliases_and_timestamp(app: Flask, mo "conversation_id": "550e8400-e29b-41d4-a716-446655440001", "inputs": {"foo": "bar"}, "query": "hello", - "re_sign_file_url_answer": "world", + "message": [{"text": "hello"}], + "message_tokens": 7, + "answer": "world", + "answer_tokens": 11, + "provider_response_latency": 1.25, "from_source": "user", + "from_end_user_id": None, + "from_account_id": "550e8400-e29b-41d4-a716-446655440002", + "feedbacks": [], + "workflow_run_id": None, + "annotation": None, + "annotation_hit_history": None, "status": "normal", "created_at": created_at, + "agent_thoughts": [], + "message_files": [], "message_metadata_dict": {"token_usage": 3}, + "error": None, + "parent_message_id": None, + "extra_contents": [], } ) assert response.answer == "world" + assert response.message_tokens == 7 + assert response.answer_tokens == 11 + assert response.provider_response_latency == 1.25 assert response.metadata == {"token_usage": 3} assert response.created_at == int(created_at.timestamp()) + assert response.model_dump(mode="json") == { + "id": "550e8400-e29b-41d4-a716-446655440000", + "conversation_id": "550e8400-e29b-41d4-a716-446655440001", + "inputs": {"foo": "bar"}, + "query": "hello", + "message": [{"text": "hello"}], + "message_tokens": 7, + "answer": "world", + "answer_tokens": 11, + "provider_response_latency": 1.25, + "from_source": "user", + "from_end_user_id": None, + "from_account_id": "550e8400-e29b-41d4-a716-446655440002", + "feedbacks": [], + "workflow_run_id": None, + "annotation": None, + "annotation_hit_history": None, + "created_at": int(created_at.timestamp()), + "agent_thoughts": [], + "message_files": [], + "metadata": {"token_usage": 3}, + "status": "normal", + "error": None, + "parent_message_id": None, + "extra_contents": [], + } diff --git a/api/tests/unit_tests/controllers/console/app/test_statistic_api.py b/api/tests/unit_tests/controllers/console/app/test_statistic_api.py index b0506e348fcce8..c51a38ad798954 100644 --- a/api/tests/unit_tests/controllers/console/app/test_statistic_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_statistic_api.py @@ -3,6 +3,7 @@ from decimal import Decimal from inspect import unwrap from types import SimpleNamespace +from typing import Any import pytest from flask import Flask @@ -39,6 +40,10 @@ def _install_common(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(statistic_module, "convert_datetime_to_date", lambda field: field) +def _json_payload(response: Any) -> dict[str, Any]: + return response if isinstance(response, dict) else response.get_json() + + def test_daily_message_statistic_returns_rows(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = statistic_module.DailyMessageStatistic() method = unwrap(api.get) @@ -50,7 +55,7 @@ def test_daily_message_statistic_returns_rows(app: Flask, monkeypatch: pytest.Mo with app.test_request_context("/console/api/apps/app-1/statistics/daily-messages", method="GET"): response = method(api, SimpleNamespace(timezone="UTC"), app_model=SimpleNamespace(id="app-1")) - assert response.get_json() == {"data": [{"date": "2024-01-01", "message_count": 3}]} + assert _json_payload(response) == {"data": [{"date": "2024-01-01", "message_count": 3}]} def test_daily_conversation_statistic_returns_rows(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -64,7 +69,7 @@ def test_daily_conversation_statistic_returns_rows(app: Flask, monkeypatch: pyte with app.test_request_context("/console/api/apps/app-1/statistics/daily-conversations", method="GET"): response = method(api, SimpleNamespace(timezone="UTC"), app_model=SimpleNamespace(id="app-1")) - assert response.get_json() == {"data": [{"date": "2024-01-02", "conversation_count": 5}]} + assert _json_payload(response) == {"data": [{"date": "2024-01-02", "conversation_count": 5}]} def test_daily_token_cost_statistic_returns_rows(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -78,11 +83,11 @@ def test_daily_token_cost_statistic_returns_rows(app: Flask, monkeypatch: pytest with app.test_request_context("/console/api/apps/app-1/statistics/token-costs", method="GET"): response = method(api, SimpleNamespace(timezone="UTC"), app_model=SimpleNamespace(id="app-1")) - data = response.get_json() + data = _json_payload(response) assert len(data["data"]) == 1 assert data["data"][0]["date"] == "2024-01-03" assert data["data"][0]["token_count"] == 10 - assert data["data"][0]["total_price"] == 0.25 + assert Decimal(data["data"][0]["total_price"]) == Decimal("0.25") def test_daily_terminals_statistic_returns_rows(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -96,7 +101,7 @@ def test_daily_terminals_statistic_returns_rows(app: Flask, monkeypatch: pytest. with app.test_request_context("/console/api/apps/app-1/statistics/daily-end-users", method="GET"): response = method(api, SimpleNamespace(timezone="UTC"), app_model=SimpleNamespace(id="app-1")) - assert response.get_json() == {"data": [{"date": "2024-01-04", "terminal_count": 7}]} + assert _json_payload(response) == {"data": [{"date": "2024-01-04", "terminal_count": 7}]} def test_average_session_interaction_statistic_requires_chat_mode(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -139,7 +144,7 @@ def test_daily_message_statistic_multiple_rows(app: Flask, monkeypatch: pytest.M with app.test_request_context("/console/api/apps/app-1/statistics/daily-messages", method="GET"): response = method(api, SimpleNamespace(timezone="UTC"), app_model=SimpleNamespace(id="app-1")) - data = response.get_json() + data = _json_payload(response) assert len(data["data"]) == 3 @@ -153,7 +158,7 @@ def test_daily_message_statistic_empty_result(app: Flask, monkeypatch: pytest.Mo with app.test_request_context("/console/api/apps/app-1/statistics/daily-messages", method="GET"): response = method(api, SimpleNamespace(timezone="UTC"), app_model=SimpleNamespace(id="app-1")) - assert response.get_json() == {"data": []} + assert _json_payload(response) == {"data": []} def test_daily_conversation_statistic_with_time_range(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -172,7 +177,7 @@ def test_daily_conversation_statistic_with_time_range(app: Flask, monkeypatch: p with app.test_request_context("/console/api/apps/app-1/statistics/daily-conversations", method="GET"): response = method(api, SimpleNamespace(timezone="UTC"), app_model=SimpleNamespace(id="app-1")) - assert response.get_json() == {"data": [{"date": "2024-01-02", "conversation_count": 5}]} + assert _json_payload(response) == {"data": [{"date": "2024-01-02", "conversation_count": 5}]} def test_daily_token_cost_with_multiple_currencies(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -189,5 +194,5 @@ def test_daily_token_cost_with_multiple_currencies(app: Flask, monkeypatch: pyte with app.test_request_context("/console/api/apps/app-1/statistics/token-costs", method="GET"): response = method(api, SimpleNamespace(timezone="UTC"), app_model=SimpleNamespace(id="app-1")) - data = response.get_json() + data = _json_payload(response) assert len(data["data"]) == 2 diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_draft_variable.py b/api/tests/unit_tests/controllers/console/app/test_workflow_draft_variable.py new file mode 100644 index 00000000000000..903fb6df631639 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_draft_variable.py @@ -0,0 +1,767 @@ +import uuid +from contextlib import nullcontext +from inspect import unwrap +from types import SimpleNamespace +from typing import Any, NamedTuple +from unittest.mock import Mock, patch + +import pytest +from flask import Flask + +from controllers.console.app import workflow_draft_variable as draft_variable_module +from controllers.console.app.workflow_draft_variable import ( + EnvironmentVariableCollectionApi, + NodeVariableCollectionApi, + VariableApi, + WorkflowDraftVariableFullContentResponse, + WorkflowDraftVariableListResponse, + WorkflowDraftVariableListWithoutValueResponse, + WorkflowDraftVariableResponse, + WorkflowDraftVariableWithoutValueResponse, + WorkflowVariableCollectionApi, +) +from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID +from factories.variable_factory import build_segment +from graphon.variables.types import SegmentType +from libs.datetime_utils import naive_utc_now +from libs.uuid_utils import uuidv7 +from models import Account, App, AppMode +from models.workflow import WorkflowDraftVariable, WorkflowDraftVariableFile +from services.workflow_draft_variable_service import WorkflowDraftVariableList + +_TEST_APP_ID = "test_app_id" +_TEST_NODE_EXEC_ID = str(uuid.uuid4()) + + +def _app_model() -> App: + app_model = App() + app_model.id = _TEST_APP_ID + app_model.tenant_id = "tenant-1" + app_model.name = "test app" + app_model.mode = AppMode.WORKFLOW + return app_model + + +def _current_user() -> Account: + account = Account(name="Test User", email="user@example.com") + account.id = "user-1" + return account + + +def _node_variable(*, value: Any = "value") -> WorkflowDraftVariable: + variable = WorkflowDraftVariable.new_node_variable( + app_id=_TEST_APP_ID, + user_id="user-1", + node_id="node-1", + name="node_var", + value=build_segment(value), + node_execution_id=_TEST_NODE_EXEC_ID, + ) + variable.id = str(uuid.uuid4()) + return variable + + +def _assert_raw_payload_matches_model(payload: dict[str, Any], model: type[Any], expected: dict[str, Any]) -> None: + assert payload == expected + assert model.model_validate(payload).model_dump(mode="json") == expected + + +def test_workflow_draft_variable_update_payload_keeps_value_as_json_until_variable_type_is_known() -> None: + payload = draft_variable_module.WorkflowDraftVariableUpdatePayload.model_validate( + {"value": {"transfer_method": "ordinary-object-field", "nested": {"enabled": True}}} + ) + + assert payload.value == {"transfer_method": "ordinary-object-field", "nested": {"enabled": True}} + + +def test_workflow_variable_collection_get_returns_without_value_contract( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + variable = _node_variable() + captured_args: dict[str, Any] = {} + + class WorkflowService: + def is_workflow_exist(self, *, app_model: Any) -> bool: + captured_args["workflow_app_id"] = app_model.id + return True + + class DraftVariableService: + def __init__(self, *, session: object) -> None: + captured_args["session"] = session + + def list_variables_without_values(self, **kwargs: Any) -> WorkflowDraftVariableList: + captured_args.update(kwargs) + return WorkflowDraftVariableList(variables=[variable], total=None) + + session = object() + monkeypatch.setattr(draft_variable_module, "WorkflowService", WorkflowService) + monkeypatch.setattr(draft_variable_module, "WorkflowDraftVariableService", DraftVariableService) + monkeypatch.setattr(draft_variable_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + draft_variable_module, + "sessionmaker", + lambda *_args, **_kwargs: SimpleNamespace(begin=lambda: nullcontext(session)), + ) + + api = WorkflowVariableCollectionApi() + handler = unwrap(api.get) + + with app.test_request_context("/apps/app-1/workflows/draft/variables?page=2&limit=3", method="GET"): + payload = handler(api, _current_user(), _app_model()) + + expected_payload = { + "items": [ + { + "id": variable.id, + "type": "node", + "name": "node_var", + "description": "", + "selector": ["node-1", "node_var"], + "value_type": "string", + "edited": False, + "visible": True, + "is_truncated": False, + } + ], + "total": None, + } + + assert captured_args == { + "workflow_app_id": _TEST_APP_ID, + "session": session, + "app_id": _TEST_APP_ID, + "page": 2, + "limit": 3, + "user_id": "user-1", + } + _assert_raw_payload_matches_model(payload, WorkflowDraftVariableListWithoutValueResponse, expected_payload) + + +def test_node_variable_collection_get_returns_value_contract(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + variable = _node_variable(value=None) + + class DraftVariableService: + def __init__(self, *, session: object) -> None: + pass + + def list_node_variables(self, app_id: str, node_id: str, *, user_id: str) -> WorkflowDraftVariableList: + assert (app_id, node_id, user_id) == (_TEST_APP_ID, "node-1", "user-1") + return WorkflowDraftVariableList(variables=[variable]) + + monkeypatch.setattr(draft_variable_module, "WorkflowDraftVariableService", DraftVariableService) + monkeypatch.setattr(draft_variable_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + draft_variable_module, + "sessionmaker", + lambda *_args, **_kwargs: SimpleNamespace(begin=lambda: nullcontext(object())), + ) + + api = NodeVariableCollectionApi() + handler = unwrap(api.get) + + with app.test_request_context("/apps/app-1/workflows/draft/nodes/node-1/variables", method="GET"): + payload = handler(api, _current_user(), _app_model(), "node-1") + + expected_payload = { + "items": [ + { + "id": variable.id, + "type": "node", + "name": "node_var", + "description": "", + "selector": ["node-1", "node_var"], + "value_type": "none", + "edited": False, + "visible": True, + "is_truncated": False, + "value": None, + "full_content": None, + } + ] + } + _assert_raw_payload_matches_model(payload, WorkflowDraftVariableListResponse, expected_payload) + + +def test_variable_patch_noop_returns_current_variable_contract(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + variable = _node_variable(value=42) + update_variable_mock = Mock() + + class DraftVariableService: + def __init__(self, session: object) -> None: + pass + + def get_variable(self, *, variable_id: str) -> WorkflowDraftVariable: + assert variable_id == variable.id + return variable + + def update_variable(self, *args: Any, **kwargs: Any) -> None: + update_variable_mock(*args, **kwargs) + + monkeypatch.setattr(draft_variable_module, "WorkflowDraftVariableService", DraftVariableService) + session = Mock(return_value=object()) + session.commit = Mock() + monkeypatch.setattr(draft_variable_module, "db", SimpleNamespace(session=session)) + + api = VariableApi() + handler = unwrap(api.patch) + + with app.test_request_context(f"/apps/app-1/workflows/draft/variables/{variable.id}", method="PATCH", json={}): + payload = handler(api, _current_user(), _app_model(), uuid.UUID(variable.id)) + + expected_payload = { + "id": variable.id, + "type": "node", + "name": "node_var", + "description": "", + "selector": ["node-1", "node_var"], + "value_type": "number", + "edited": False, + "visible": True, + "is_truncated": False, + "value": 42, + "full_content": None, + } + + update_variable_mock.assert_not_called() + _assert_raw_payload_matches_model(payload, WorkflowDraftVariableResponse, expected_payload) + + +def test_variable_patch_file_value_forwards_raw_mapping_to_file_factory( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + variable = _node_variable(value="old") + variable.value_type = SegmentType.FILE + raw_mapping = { + "transfer_method": "local_file", + "upload_file_id": "file-1", + "filename": "kept-for-file-factory", + } + built_file = object() + captured: dict[str, Any] = {} + + def build_from_mapping(**kwargs: Any) -> object: + captured.update(kwargs) + return built_file + + def build_segment_with_type(segment_type: SegmentType, value: object): + assert segment_type == SegmentType.FILE + assert value is built_file + return build_segment("updated") + + class DraftVariableService: + def __init__(self, session: object) -> None: + pass + + def get_variable(self, *, variable_id: str) -> WorkflowDraftVariable: + assert variable_id == variable.id + return variable + + def update_variable(self, target: WorkflowDraftVariable, *, name: str | None, value: Any) -> None: + assert target is variable + assert name is None + target.set_value(value) + + monkeypatch.setattr(draft_variable_module, "WorkflowDraftVariableService", DraftVariableService) + monkeypatch.setattr(draft_variable_module, "build_from_mapping", build_from_mapping) + monkeypatch.setattr(draft_variable_module, "build_segment_with_type", build_segment_with_type) + session = Mock(return_value=object()) + session.commit = Mock() + monkeypatch.setattr(draft_variable_module, "db", SimpleNamespace(session=session)) + + api = VariableApi() + handler = unwrap(api.patch) + + with app.test_request_context( + f"/apps/app-1/workflows/draft/variables/{variable.id}", + method="PATCH", + json={"value": raw_mapping}, + ): + payload = handler(api, _current_user(), _app_model(), uuid.UUID(variable.id)) + + assert captured["tenant_id"] == "tenant-1" + assert captured["mapping"] == raw_mapping + expected_payload = { + "id": variable.id, + "type": "node", + "name": "node_var", + "description": "", + "selector": ["node-1", "node_var"], + "value_type": "string", + "edited": False, + "visible": True, + "is_truncated": False, + "value": "updated", + "full_content": None, + } + _assert_raw_payload_matches_model(payload, WorkflowDraftVariableResponse, expected_payload) + + +def test_environment_variable_collection_get_returns_response_model_contract( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + env_var = SimpleNamespace( + id="env-1", + name="API_KEY", + description="secret token", + selector=["env", "API_KEY"], + value_type=SegmentType.SECRET, + value="token", + ) + + class WorkflowService: + def get_draft_workflow(self, *, app_model: Any) -> SimpleNamespace: + assert app_model.id == _TEST_APP_ID + return SimpleNamespace(environment_variables=[env_var]) + + monkeypatch.setattr(draft_variable_module, "WorkflowService", WorkflowService) + + api = EnvironmentVariableCollectionApi() + handler = unwrap(api.get) + + with app.test_request_context("/apps/app-1/workflows/draft/environment-variables", method="GET"): + payload = handler(api, _current_user(), _app_model()) + + expected_payload = { + "items": [ + { + "id": "env-1", + "type": "env", + "name": "API_KEY", + "description": "secret token", + "selector": ["env", "API_KEY"], + "value_type": "secret", + "value": "token", + "edited": False, + "visible": True, + "editable": True, + } + ] + } + _assert_raw_payload_matches_model( + payload, + draft_variable_module.WorkflowDraftEnvironmentVariableListResponse, + expected_payload, + ) + + +class TestWorkflowDraftVariableFields: + def test_full_content_response_constructor(self): + """Test that full_content serialization uses pre-loaded relationships.""" + # Create mock objects with relationships pre-loaded + mock_variable = WorkflowDraftVariable( + file_id="test-file-id", + variable_file=WorkflowDraftVariableFile( + size=100000, + length=50, + value_type=SegmentType.OBJECT, + upload_file_id="test-upload-file-id", + tenant_id=str(uuid.uuid4()), + app_id=str(uuid.uuid4()), + user_id=str(uuid.uuid4()), + ), + ) + + # Mock the file helpers + with patch("controllers.console.app.workflow_draft_variable.file_helpers", autospec=True) as mock_file_helpers: + mock_file_helpers.get_signed_file_url.return_value = "http://example.com/signed-url" + + # Call the function + result = WorkflowDraftVariableFullContentResponse.from_workflow_draft_variable(mock_variable) + + # Verify it returns the expected structure + assert result is not None + assert result.size_bytes == 100000 + assert result.length == 50 + assert result.value_type == "object" + assert result.download_url == "http://example.com/signed-url" + + # Verify it used the pre-loaded relationships (no database queries) + mock_file_helpers.get_signed_file_url.assert_called_once_with("test-upload-file-id", as_attachment=True) + + def test_full_content_response_constructor_handles_none_cases(self): + """Test that full_content serialization handles None cases properly.""" + + # Test with no file_id + draft_var = WorkflowDraftVariable() + draft_var.file_id = None + result = WorkflowDraftVariableFullContentResponse.from_workflow_draft_variable(draft_var) + assert result is None + + def test_full_content_response_constructor_preserves_none_size(self): + draft_var = WorkflowDraftVariable( + file_id="test-file-id", + variable_file=WorkflowDraftVariableFile( + size=None, + length=50, + value_type=SegmentType.OBJECT, + upload_file_id="test-upload-file-id", + tenant_id=str(uuid.uuid4()), + app_id=str(uuid.uuid4()), + user_id=str(uuid.uuid4()), + ), + ) + + with patch("controllers.console.app.workflow_draft_variable.file_helpers", autospec=True) as mock_file_helpers: + mock_file_helpers.get_signed_file_url.return_value = "http://example.com/signed-url" + + result = WorkflowDraftVariableFullContentResponse.from_workflow_draft_variable(draft_var) + + assert result is not None + assert result.size_bytes is None + + def test_full_content_response_constructor_should_raises_when_file_id_exists_but_file_is_none(self): + # Test with no file_id + draft_var = WorkflowDraftVariable() + draft_var.file_id = str(uuid.uuid4()) + draft_var.variable_file = None + with pytest.raises(AssertionError): + result = WorkflowDraftVariableFullContentResponse.from_workflow_draft_variable(draft_var) + + def test_conversation_variable(self): + conv_var = WorkflowDraftVariable.new_conversation_variable( + app_id=_TEST_APP_ID, name="conv_var", value=build_segment(1) + ) + + conv_var.id = str(uuid.uuid4()) + conv_var.visible = True + + expected_without_value: dict[str, Any] = { + "id": conv_var.id, + "type": conv_var.get_variable_type().value, + "name": "conv_var", + "description": "", + "selector": [CONVERSATION_VARIABLE_NODE_ID, "conv_var"], + "value_type": "number", + "edited": False, + "visible": True, + "is_truncated": False, + } + + assert ( + WorkflowDraftVariableWithoutValueResponse.from_workflow_draft_variable(conv_var).model_dump(mode="json") + == expected_without_value + ) + expected_with_value = expected_without_value.copy() + expected_with_value["value"] = 1 + expected_with_value["full_content"] = None + assert ( + WorkflowDraftVariableResponse.from_workflow_draft_variable(conv_var).model_dump(mode="json") + == expected_with_value + ) + + def test_create_sys_variable(self): + sys_var = WorkflowDraftVariable.new_sys_variable( + app_id=_TEST_APP_ID, + name="sys_var", + value=build_segment("a"), + editable=True, + node_execution_id=_TEST_NODE_EXEC_ID, + ) + + sys_var.id = str(uuid.uuid4()) + sys_var.last_edited_at = naive_utc_now() + sys_var.visible = True + + expected_without_value: dict[str, Any] = { + "id": sys_var.id, + "type": sys_var.get_variable_type().value, + "name": "sys_var", + "description": "", + "selector": [SYSTEM_VARIABLE_NODE_ID, "sys_var"], + "value_type": "string", + "edited": True, + "visible": True, + "is_truncated": False, + } + assert ( + WorkflowDraftVariableWithoutValueResponse.from_workflow_draft_variable(sys_var).model_dump(mode="json") + == expected_without_value + ) + expected_with_value = expected_without_value.copy() + expected_with_value["value"] = "a" + expected_with_value["full_content"] = None + assert ( + WorkflowDraftVariableResponse.from_workflow_draft_variable(sys_var).model_dump(mode="json") + == expected_with_value + ) + + def test_node_variable(self): + node_var = WorkflowDraftVariable.new_node_variable( + app_id=_TEST_APP_ID, + node_id="test_node", + name="node_var", + value=build_segment([1, "a"]), + visible=False, + node_execution_id=_TEST_NODE_EXEC_ID, + ) + + node_var.id = str(uuid.uuid4()) + node_var.last_edited_at = naive_utc_now() + + expected_without_value: dict[str, Any] = { + "id": node_var.id, + "type": node_var.get_variable_type().value, + "name": "node_var", + "description": "", + "selector": ["test_node", "node_var"], + "value_type": "array[any]", + "edited": True, + "visible": False, + "is_truncated": False, + } + + assert ( + WorkflowDraftVariableWithoutValueResponse.from_workflow_draft_variable(node_var).model_dump(mode="json") + == expected_without_value + ) + expected_with_value = expected_without_value.copy() + expected_with_value["value"] = [1, "a"] + expected_with_value["full_content"] = None + assert ( + WorkflowDraftVariableResponse.from_workflow_draft_variable(node_var).model_dump(mode="json") + == expected_with_value + ) + + def test_node_variable_with_file(self): + node_var = WorkflowDraftVariable.new_node_variable( + app_id=_TEST_APP_ID, + node_id="test_node", + name="node_var", + value=build_segment([1, "a"]), + visible=False, + node_execution_id=_TEST_NODE_EXEC_ID, + ) + + node_var.id = str(uuid.uuid4()) + node_var.last_edited_at = naive_utc_now() + variable_file = WorkflowDraftVariableFile( + upload_file_id=str(uuid.uuid4()), + size=1024, + length=10, + value_type=SegmentType.ARRAY_STRING, + tenant_id=str(uuidv7()), + app_id=str(uuidv7()), + user_id=str(uuidv7()), + ) + variable_file.id = str(uuidv7()) + node_var.variable_file = variable_file + node_var.file_id = variable_file.id + + expected_without_value: dict[str, Any] = { + "id": node_var.id, + "type": node_var.get_variable_type().value, + "name": "node_var", + "description": "", + "selector": ["test_node", "node_var"], + "value_type": "array[any]", + "edited": True, + "visible": False, + "is_truncated": True, + } + + with patch("controllers.console.app.workflow_draft_variable.file_helpers", autospec=True) as mock_file_helpers: + mock_file_helpers.get_signed_file_url.return_value = "http://example.com/signed-url" + assert ( + WorkflowDraftVariableWithoutValueResponse.from_workflow_draft_variable(node_var).model_dump(mode="json") + == expected_without_value + ) + expected_with_value = expected_without_value.copy() + expected_with_value["value"] = [1, "a"] + expected_with_value["full_content"] = { + "size_bytes": 1024, + "value_type": "array[string]", + "length": 10, + "download_url": "http://example.com/signed-url", + } + assert ( + WorkflowDraftVariableResponse.from_workflow_draft_variable(node_var).model_dump(mode="json") + == expected_with_value + ) + + +class TestWorkflowDraftVariableList: + def test_workflow_draft_variable_list(self): + class TestCase(NamedTuple): + name: str + var_list: WorkflowDraftVariableList + expected: dict + + node_var = WorkflowDraftVariable.new_node_variable( + app_id=_TEST_APP_ID, + node_id="test_node", + name="test_var", + value=build_segment("a"), + visible=True, + node_execution_id=_TEST_NODE_EXEC_ID, + ) + node_var.id = str(uuid.uuid4()) + node_var_dict = { + "id": node_var.id, + "type": node_var.get_variable_type().value, + "name": "test_var", + "description": "", + "selector": ["test_node", "test_var"], + "value_type": "string", + "edited": False, + "visible": True, + "is_truncated": False, + } + + cases = [ + TestCase( + name="empty variable list", + var_list=WorkflowDraftVariableList(variables=[]), + expected={ + "items": [], + "total": None, + }, + ), + TestCase( + name="empty variable list with total", + var_list=WorkflowDraftVariableList(variables=[], total=10), + expected={ + "items": [], + "total": 10, + }, + ), + TestCase( + name="non-empty variable list", + var_list=WorkflowDraftVariableList(variables=[node_var], total=None), + expected={ + "items": [node_var_dict], + "total": None, + }, + ), + TestCase( + name="non-empty variable list with total", + var_list=WorkflowDraftVariableList(variables=[node_var], total=10), + expected={ + "items": [node_var_dict], + "total": 10, + }, + ), + ] + + for idx, case in enumerate(cases, 1): + assert ( + WorkflowDraftVariableListWithoutValueResponse.from_workflow_draft_variable_list( + case.var_list + ).model_dump(mode="json") + == case.expected + ), f"Test case {idx} failed, {case.name=}" + + +def test_workflow_node_variables_fields(): + conv_var = WorkflowDraftVariable.new_conversation_variable( + app_id=_TEST_APP_ID, name="conv_var", value=build_segment(1) + ) + conv_var.visible = True + resp = WorkflowDraftVariableListResponse.from_workflow_draft_variable_list( + WorkflowDraftVariableList(variables=[conv_var]) + ).model_dump(mode="json") + assert isinstance(resp, dict) + assert len(resp["items"]) == 1 + item_dict = resp["items"][0] + assert item_dict["name"] == "conv_var" + assert item_dict["value"] == 1 + + +def test_workflow_file_variable_with_signed_url(): + """Test that File type variables include signed URLs in API responses.""" + from graphon.file import File, FileTransferMethod, FileType + + # Create a File object with LOCAL_FILE transfer method (which generates signed URLs) + test_file = File( + file_id="test_file_id", + file_type=FileType.IMAGE, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="test_upload_file_id", + filename="test.jpg", + extension=".jpg", + mime_type="image/jpeg", + size=12345, + ) + + # Create a WorkflowDraftVariable with the File + file_var = WorkflowDraftVariable.new_node_variable( + app_id=_TEST_APP_ID, + node_id="test_node", + name="file_var", + value=build_segment(test_file), + node_execution_id=_TEST_NODE_EXEC_ID, + ) + + resp = WorkflowDraftVariableListResponse.from_workflow_draft_variable_list( + WorkflowDraftVariableList(variables=[file_var]) + ).model_dump(mode="json") + + # Verify the response structure + assert isinstance(resp, dict) + assert len(resp["items"]) == 1 + item_dict = resp["items"][0] + assert item_dict["name"] == "file_var" + + # Verify the value is a dict (File.to_dict() result) and contains expected fields + value = item_dict["value"] + assert isinstance(value, dict) + + # Verify the File fields are preserved + assert value["id"] == test_file.id + assert value["filename"] == test_file.filename + assert value["type"] == test_file.type.value + assert value["transfer_method"] == test_file.transfer_method.value + assert value["size"] == test_file.size + + # Verify the URL is present (it should be a signed URL for LOCAL_FILE transfer method) + remote_url = value["remote_url"] + assert remote_url is not None + + assert isinstance(remote_url, str) + # For LOCAL_FILE, the URL should contain signature parameters + assert "timestamp=" in remote_url + assert "nonce=" in remote_url + assert "sign=" in remote_url + + +def test_workflow_file_variable_remote_url(): + """Test that File type variables with REMOTE_URL transfer method return the remote URL.""" + from graphon.file import File, FileTransferMethod, FileType + + # Create a File object with REMOTE_URL transfer method + test_file = File( + file_id="test_file_id", + file_type=FileType.IMAGE, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.com/test.jpg", + filename="test.jpg", + extension=".jpg", + mime_type="image/jpeg", + size=12345, + ) + + # Create a WorkflowDraftVariable with the File + file_var = WorkflowDraftVariable.new_node_variable( + app_id=_TEST_APP_ID, + node_id="test_node", + name="file_var", + value=build_segment(test_file), + node_execution_id=_TEST_NODE_EXEC_ID, + ) + + resp = WorkflowDraftVariableListResponse.from_workflow_draft_variable_list( + WorkflowDraftVariableList(variables=[file_var]) + ).model_dump(mode="json") + + # Verify the response structure + assert isinstance(resp, dict) + assert len(resp["items"]) == 1 + item_dict = resp["items"][0] + assert item_dict["name"] == "file_var" + + # Verify the value is a dict (File.to_dict() result) and contains expected fields + value = item_dict["value"] + assert isinstance(value, dict) + remote_url = value["remote_url"] + + # For REMOTE_URL, the URL should be the original remote URL + assert remote_url == test_file.remote_url diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py index f04ab6d6e7c32c..9c47f8e5a315fb 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py @@ -77,9 +77,11 @@ def test_human_input_preview_delegates_to_service( preview_payload = { "form_id": "node-42", + "node_id": "node-42", + "node_title": "Human Input", "form_content": "
example
", "inputs": [{"name": "topic"}], - "actions": [{"id": "continue"}], + "actions": [{"id": "continue", "title": "Continue"}], } service_instance = MagicMock() service_instance.get_human_input_form_preview.return_value = preview_payload @@ -88,7 +90,15 @@ def test_human_input_preview_delegates_to_service( with app.test_request_context(case.path, method="POST", json={"inputs": {"topic": "tech"}}): response = case.resource_cls().post(app_id=app_model.id, node_id="node-42") - assert response == preview_payload + assert response == { + **preview_payload, + "TYPE": "human_input_required", + "actions": [{"id": "continue", "title": "Continue", "button_style": "default"}], + "resolved_default_values": {}, + "display_in_ui": False, + "form_token": None, + "expiration_time": None, + } service_instance.get_human_input_form_preview.assert_called_once_with( app_model=app_model, account=account, diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py index 71034ebd405281..f029c4ff822284 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py @@ -3,27 +3,14 @@ from datetime import UTC, datetime from inspect import unwrap from types import SimpleNamespace -from typing import Any import pytest from flask import Flask -from flask_restx import marshal from controllers.console.app import workflow_run as workflow_run_module from models import Account -def _serialize_200_response(handler, payload: Any) -> Any: - response_doc = getattr(handler, "__apidoc__", {}).get("responses", {}).get("200") - if response_doc is None: - return payload - - response_model = response_doc[1] - if isinstance(response_model, dict): - return marshal(payload, response_model) - return payload - - def _account() -> SimpleNamespace: return SimpleNamespace(id="account-1", name="Alice", email="alice@example.com") @@ -100,11 +87,9 @@ def get_paginate_workflow_runs(self, **_kwargs): with app.test_request_context("/apps/app-1/workflow-runs?limit=10", method="GET"): payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1")) - response = _serialize_200_response(api.get, payload) - - assert response["limit"] == 10 - assert response["has_more"] is False - assert response["data"][0] == { + assert payload["limit"] == 10 + assert payload["has_more"] is False + assert payload["data"][0] == { "id": "run-1", "version": "v1", "status": "succeeded", @@ -141,10 +126,8 @@ def get_paginate_advanced_chat_workflow_runs(self, **_kwargs): with app.test_request_context("/apps/app-1/advanced-chat/workflow-runs?limit=1", method="GET"): payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1")) - response = _serialize_200_response(api.get, payload) - - assert response["data"][0]["conversation_id"] == "conversation-1" - assert response["data"][0]["message_id"] == "message-1" + assert payload["data"][0]["conversation_id"] == "conversation-1" + assert payload["data"][0]["message_id"] == "message-1" def test_workflow_run_detail_returns_frontend_detail_contract(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -180,9 +163,7 @@ def get_workflow_run(self, **_kwargs): with app.test_request_context("/apps/app-1/workflow-runs/run-1", method="GET"): payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"), run_id="run-1") - response = _serialize_200_response(api.get, payload) - - assert response == { + assert payload == { "id": "run-1", "version": "v1", "graph": {"nodes": []}, @@ -219,9 +200,7 @@ def get_workflow_run_node_executions(self, **_kwargs): api, _current_account(), app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"), run_id="run-1" ) - response = _serialize_200_response(api.get, payload) - - assert response == { + assert payload == { "data": [ { "id": "node-exec-1", diff --git a/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py b/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py deleted file mode 100644 index 62fa82e3397584..00000000000000 --- a/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py +++ /dev/null @@ -1,413 +0,0 @@ -import uuid -from collections import OrderedDict -from typing import Any, NamedTuple -from unittest.mock import patch - -import pytest -from flask_restx import marshal - -from controllers.console.app.workflow_draft_variable import ( - _WORKFLOW_DRAFT_VARIABLE_FIELDS, - _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS, - _WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS, - _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS, - _serialize_full_content, -) -from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID -from factories.variable_factory import build_segment -from graphon.variables.types import SegmentType -from libs.datetime_utils import naive_utc_now -from libs.uuid_utils import uuidv7 -from models.workflow import WorkflowDraftVariable, WorkflowDraftVariableFile -from services.workflow_draft_variable_service import WorkflowDraftVariableList - -_TEST_APP_ID = "test_app_id" -_TEST_NODE_EXEC_ID = str(uuid.uuid4()) - - -class TestWorkflowDraftVariableFields: - def test_serialize_full_content(self): - """Test that _serialize_full_content uses pre-loaded relationships.""" - # Create mock objects with relationships pre-loaded - mock_variable = WorkflowDraftVariable( - file_id="test-file-id", - variable_file=WorkflowDraftVariableFile( - size=100000, - length=50, - value_type=SegmentType.OBJECT, - upload_file_id="test-upload-file-id", - tenant_id=str(uuid.uuid4()), - app_id=str(uuid.uuid4()), - user_id=str(uuid.uuid4()), - ), - ) - - # Mock the file helpers - with patch("controllers.console.app.workflow_draft_variable.file_helpers", autospec=True) as mock_file_helpers: - mock_file_helpers.get_signed_file_url.return_value = "http://example.com/signed-url" - - # Call the function - result = _serialize_full_content(mock_variable) - - # Verify it returns the expected structure - assert result is not None - assert result["size_bytes"] == 100000 - assert result["length"] == 50 - assert result["value_type"] == "object" - assert "download_url" in result - assert result["download_url"] == "http://example.com/signed-url" - - # Verify it used the pre-loaded relationships (no database queries) - mock_file_helpers.get_signed_file_url.assert_called_once_with("test-upload-file-id", as_attachment=True) - - def test_serialize_full_content_handles_none_cases(self): - """Test that _serialize_full_content handles None cases properly.""" - - # Test with no file_id - draft_var = WorkflowDraftVariable() - draft_var.file_id = None - result = _serialize_full_content(draft_var) - assert result is None - - def test_serialize_full_content_should_raises_when_file_id_exists_but_file_is_none(self): - # Test with no file_id - draft_var = WorkflowDraftVariable() - draft_var.file_id = str(uuid.uuid4()) - draft_var.variable_file = None - with pytest.raises(AssertionError): - result = _serialize_full_content(draft_var) - - def test_conversation_variable(self): - conv_var = WorkflowDraftVariable.new_conversation_variable( - app_id=_TEST_APP_ID, name="conv_var", value=build_segment(1) - ) - - conv_var.id = str(uuid.uuid4()) - conv_var.visible = True - - expected_without_value: OrderedDict[str, Any] = OrderedDict( - { - "id": conv_var.id, - "type": conv_var.get_variable_type().value, - "name": "conv_var", - "description": "", - "selector": [CONVERSATION_VARIABLE_NODE_ID, "conv_var"], - "value_type": "number", - "edited": False, - "visible": True, - "is_truncated": False, - } - ) - - assert marshal(conv_var, _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS) == expected_without_value - expected_with_value = expected_without_value.copy() - expected_with_value["value"] = 1 - expected_with_value["full_content"] = None - assert marshal(conv_var, _WORKFLOW_DRAFT_VARIABLE_FIELDS) == expected_with_value - - def test_create_sys_variable(self): - sys_var = WorkflowDraftVariable.new_sys_variable( - app_id=_TEST_APP_ID, - name="sys_var", - value=build_segment("a"), - editable=True, - node_execution_id=_TEST_NODE_EXEC_ID, - ) - - sys_var.id = str(uuid.uuid4()) - sys_var.last_edited_at = naive_utc_now() - sys_var.visible = True - - expected_without_value = OrderedDict( - { - "id": sys_var.id, - "type": sys_var.get_variable_type().value, - "name": "sys_var", - "description": "", - "selector": [SYSTEM_VARIABLE_NODE_ID, "sys_var"], - "value_type": "string", - "edited": True, - "visible": True, - "is_truncated": False, - } - ) - assert marshal(sys_var, _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS) == expected_without_value - expected_with_value = expected_without_value.copy() - expected_with_value["value"] = "a" - expected_with_value["full_content"] = None - assert marshal(sys_var, _WORKFLOW_DRAFT_VARIABLE_FIELDS) == expected_with_value - - def test_node_variable(self): - node_var = WorkflowDraftVariable.new_node_variable( - app_id=_TEST_APP_ID, - node_id="test_node", - name="node_var", - value=build_segment([1, "a"]), - visible=False, - node_execution_id=_TEST_NODE_EXEC_ID, - ) - - node_var.id = str(uuid.uuid4()) - node_var.last_edited_at = naive_utc_now() - - expected_without_value: OrderedDict[str, Any] = OrderedDict( - { - "id": node_var.id, - "type": node_var.get_variable_type().value, - "name": "node_var", - "description": "", - "selector": ["test_node", "node_var"], - "value_type": "array[any]", - "edited": True, - "visible": False, - "is_truncated": False, - } - ) - - assert marshal(node_var, _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS) == expected_without_value - expected_with_value = expected_without_value.copy() - expected_with_value["value"] = [1, "a"] - expected_with_value["full_content"] = None - assert marshal(node_var, _WORKFLOW_DRAFT_VARIABLE_FIELDS) == expected_with_value - - def test_node_variable_with_file(self): - node_var = WorkflowDraftVariable.new_node_variable( - app_id=_TEST_APP_ID, - node_id="test_node", - name="node_var", - value=build_segment([1, "a"]), - visible=False, - node_execution_id=_TEST_NODE_EXEC_ID, - ) - - node_var.id = str(uuid.uuid4()) - node_var.last_edited_at = naive_utc_now() - variable_file = WorkflowDraftVariableFile( - upload_file_id=str(uuid.uuid4()), - size=1024, - length=10, - value_type=SegmentType.ARRAY_STRING, - tenant_id=str(uuidv7()), - app_id=str(uuidv7()), - user_id=str(uuidv7()), - ) - variable_file.id = str(uuidv7()) - node_var.variable_file = variable_file - node_var.file_id = variable_file.id - - expected_without_value: OrderedDict[str, Any] = OrderedDict( - { - "id": node_var.id, - "type": node_var.get_variable_type(), - "name": "node_var", - "description": "", - "selector": ["test_node", "node_var"], - "value_type": "array[any]", - "edited": True, - "visible": False, - "is_truncated": True, - } - ) - - with patch("controllers.console.app.workflow_draft_variable.file_helpers", autospec=True) as mock_file_helpers: - mock_file_helpers.get_signed_file_url.return_value = "http://example.com/signed-url" - assert marshal(node_var, _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS) == expected_without_value - expected_with_value = expected_without_value.copy() - expected_with_value["value"] = [1, "a"] - expected_with_value["full_content"] = { - "size_bytes": 1024, - "value_type": "array[string]", - "length": 10, - "download_url": "http://example.com/signed-url", - } - assert marshal(node_var, _WORKFLOW_DRAFT_VARIABLE_FIELDS) == expected_with_value - - -class TestWorkflowDraftVariableList: - def test_workflow_draft_variable_list(self): - class TestCase(NamedTuple): - name: str - var_list: WorkflowDraftVariableList - expected: dict - - node_var = WorkflowDraftVariable.new_node_variable( - app_id=_TEST_APP_ID, - node_id="test_node", - name="test_var", - value=build_segment("a"), - visible=True, - node_execution_id=_TEST_NODE_EXEC_ID, - ) - node_var.id = str(uuid.uuid4()) - node_var_dict = OrderedDict( - { - "id": node_var.id, - "type": node_var.get_variable_type().value, - "name": "test_var", - "description": "", - "selector": ["test_node", "test_var"], - "value_type": "string", - "edited": False, - "visible": True, - "is_truncated": False, - } - ) - - cases = [ - TestCase( - name="empty variable list", - var_list=WorkflowDraftVariableList(variables=[]), - expected=OrderedDict( - { - "items": [], - "total": None, - } - ), - ), - TestCase( - name="empty variable list with total", - var_list=WorkflowDraftVariableList(variables=[], total=10), - expected=OrderedDict( - { - "items": [], - "total": 10, - } - ), - ), - TestCase( - name="non-empty variable list", - var_list=WorkflowDraftVariableList(variables=[node_var], total=None), - expected=OrderedDict( - { - "items": [node_var_dict], - "total": None, - } - ), - ), - TestCase( - name="non-empty variable list with total", - var_list=WorkflowDraftVariableList(variables=[node_var], total=10), - expected=OrderedDict( - { - "items": [node_var_dict], - "total": 10, - } - ), - ), - ] - - for idx, case in enumerate(cases, 1): - assert marshal(case.var_list, _WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS) == case.expected, ( - f"Test case {idx} failed, {case.name=}" - ) - - -def test_workflow_node_variables_fields(): - conv_var = WorkflowDraftVariable.new_conversation_variable( - app_id=_TEST_APP_ID, name="conv_var", value=build_segment(1) - ) - resp = marshal(WorkflowDraftVariableList(variables=[conv_var]), _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS) - assert isinstance(resp, dict) - assert len(resp["items"]) == 1 - item_dict = resp["items"][0] - assert item_dict["name"] == "conv_var" - assert item_dict["value"] == 1 - - -def test_workflow_file_variable_with_signed_url(): - """Test that File type variables include signed URLs in API responses.""" - from graphon.file import File, FileTransferMethod, FileType - - # Create a File object with LOCAL_FILE transfer method (which generates signed URLs) - test_file = File( - file_id="test_file_id", - file_type=FileType.IMAGE, - transfer_method=FileTransferMethod.LOCAL_FILE, - related_id="test_upload_file_id", - filename="test.jpg", - extension=".jpg", - mime_type="image/jpeg", - size=12345, - ) - - # Create a WorkflowDraftVariable with the File - file_var = WorkflowDraftVariable.new_node_variable( - app_id=_TEST_APP_ID, - node_id="test_node", - name="file_var", - value=build_segment(test_file), - node_execution_id=_TEST_NODE_EXEC_ID, - ) - - # Marshal the variable using the API fields - resp = marshal(WorkflowDraftVariableList(variables=[file_var]), _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS) - - # Verify the response structure - assert isinstance(resp, dict) - assert len(resp["items"]) == 1 - item_dict = resp["items"][0] - assert item_dict["name"] == "file_var" - - # Verify the value is a dict (File.to_dict() result) and contains expected fields - value = item_dict["value"] - assert isinstance(value, dict) - - # Verify the File fields are preserved - assert value["id"] == test_file.id - assert value["filename"] == test_file.filename - assert value["type"] == test_file.type.value - assert value["transfer_method"] == test_file.transfer_method.value - assert value["size"] == test_file.size - - # Verify the URL is present (it should be a signed URL for LOCAL_FILE transfer method) - remote_url = value["remote_url"] - assert remote_url is not None - - assert isinstance(remote_url, str) - # For LOCAL_FILE, the URL should contain signature parameters - assert "timestamp=" in remote_url - assert "nonce=" in remote_url - assert "sign=" in remote_url - - -def test_workflow_file_variable_remote_url(): - """Test that File type variables with REMOTE_URL transfer method return the remote URL.""" - from graphon.file import File, FileTransferMethod, FileType - - # Create a File object with REMOTE_URL transfer method - test_file = File( - file_id="test_file_id", - file_type=FileType.IMAGE, - transfer_method=FileTransferMethod.REMOTE_URL, - remote_url="https://example.com/test.jpg", - filename="test.jpg", - extension=".jpg", - mime_type="image/jpeg", - size=12345, - ) - - # Create a WorkflowDraftVariable with the File - file_var = WorkflowDraftVariable.new_node_variable( - app_id=_TEST_APP_ID, - node_id="test_node", - name="file_var", - value=build_segment(test_file), - node_execution_id=_TEST_NODE_EXEC_ID, - ) - - # Marshal the variable using the API fields - resp = marshal(WorkflowDraftVariableList(variables=[file_var]), _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS) - - # Verify the response structure - assert isinstance(resp, dict) - assert len(resp["items"]) == 1 - item_dict = resp["items"][0] - assert item_dict["name"] == "file_var" - - # Verify the value is a dict (File.to_dict() result) and contains expected fields - value = item_dict["value"] - assert isinstance(value, dict) - remote_url = value["remote_url"] - - # For REMOTE_URL, the URL should be the original remote URL - assert remote_url == test_file.remote_url diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py index e8faece89ca036..e56bfe4adb5ad9 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py @@ -1,4 +1,5 @@ import inspect +from datetime import UTC, datetime from unittest.mock import MagicMock, patch import pytest @@ -23,6 +24,76 @@ from services.datasource_provider_service import DatasourceProviderService from services.plugin.oauth_service import OAuthProxyService +_PROVIDER_ID = "langgenius/notion_datasource/notion" + + +def _i18n(text: str) -> dict[str, str]: + return {"en_US": text, "zh_Hans": text, "pt_BR": text, "ja_JP": text} + + +def _provider_config(name: str, type_: str, label: str, *, required: bool = True) -> dict: + return { + "type": type_, + "name": name, + "scope": None, + "required": required, + "default": None, + "options": None, + "multiple": False, + "label": _i18n(label), + "help": None, + "url": None, + "placeholder": None, + } + + +def _datasource_credential(credential_id: str = "cred-1", *, is_default: bool = True) -> dict: + return { + "credential": { + "api_key": "******", + "workspace": "engineering", + "database_id": "db-123", + }, + "type": "api-key", + "name": "API Key", + "avatar_url": "https://cdn.example.com/notion.png", + "id": credential_id, + "is_default": is_default, + } + + +def _datasource_auth() -> dict: + return { + "author": "Dify", + "provider": "notion", + "plugin_id": "langgenius/notion_datasource", + "plugin_unique_identifier": "langgenius/notion_datasource:0.0.1", + "icon": "icon.svg", + "name": "notion", + "label": _i18n("Notion"), + "description": _i18n("Notion datasource"), + "credential_schema": [ + _provider_config("api_key", "secret-input", "API key"), + ], + "oauth_schema": { + "client_schema": [ + _provider_config("client_id", "text-input", "Client ID"), + ], + "credentials_schema": [ + _provider_config("access_token", "secret-input", "Access token"), + ], + "oauth_custom_client_params": {"client_id": "masked-client", "client_secret": "********"}, + "is_oauth_custom_client_enabled": True, + "is_system_oauth_params_exists": True, + "redirect_uri": "https://api.example.com/oauth/callback", + }, + "credentials_list": [_datasource_credential(), _datasource_credential("cred-2", is_default=False)], + } + + +def _success_response() -> dict[str, str]: + return {"result": "success"} + class TestDatasourcePluginOAuthAuthorizationUrl: def test_get_success(self, app: Flask): @@ -30,28 +101,50 @@ def test_get_success(self, app: Flask): method = inspect.unwrap(api.get) user = MagicMock(id="user-1") + oauth_client = {"client_id": "abc", "client_secret": "shh", "scopes": ["read", "write"]} + auth_url_payload = { + "authorization_url": "https://auth.example.com/oauth?client_id=abc&state=xyz", + } with ( app.test_request_context("/?credential_id=cred-1"), patch.object( DatasourceProviderService, "get_oauth_client", - return_value={"client_id": "abc"}, - ), + return_value=oauth_client, + ) as get_oauth_client, patch.object( OAuthProxyService, "create_proxy_context", return_value="ctx-1", - ), + ) as create_proxy_context, patch.object( OAuthHandler, "get_authorization_url", - return_value={"url": "http://auth"}, - ), + return_value=auth_url_payload, + ) as get_authorization_url, ): - response = method(api, "tenant-1", user, "notion") + response = method(api, "tenant-1", user, _PROVIDER_ID) assert response.status_code == 200 + assert response.get_json() == auth_url_payload + assert "context_id=ctx-1" in response.headers.get("Set-Cookie") + provider_id = get_oauth_client.call_args.kwargs["datasource_provider_id"] + assert str(provider_id) == _PROVIDER_ID + get_oauth_client.assert_called_once() + create_proxy_context.assert_called_once_with( + user_id="user-1", + tenant_id="tenant-1", + plugin_id="langgenius/notion_datasource", + provider="notion", + credential_id="cred-1", + ) + get_authorization_url.assert_called_once() + assert get_authorization_url.call_args.kwargs["tenant_id"] == "tenant-1" + assert get_authorization_url.call_args.kwargs["user_id"] == "user-1" + assert get_authorization_url.call_args.kwargs["plugin_id"] == "langgenius/notion_datasource" + assert get_authorization_url.call_args.kwargs["provider"] == "notion" + assert get_authorization_url.call_args.kwargs["system_credentials"] == oauth_client def test_get_no_oauth_config(self, app: Flask): api = DatasourcePluginOAuthAuthorizationUrl() @@ -90,10 +183,10 @@ def test_get_without_credential_id_sets_cookie(self, app: Flask): patch.object( OAuthHandler, "get_authorization_url", - return_value={"url": "http://auth"}, + return_value={"authorization_url": "http://auth"}, ), ): - response = method(api, "tenant-1", user, "notion") + response = method(api, "tenant-1", user, _PROVIDER_ID) assert response.status_code == 200 assert "context_id" in response.headers.get("Set-Cookie") @@ -106,8 +199,9 @@ def test_callback_success_new_credential(self, app: Flask): oauth_response = MagicMock() oauth_response.credentials = {"token": "abc"} - oauth_response.expires_at = None - oauth_response.metadata = {"name": "test"} + expires_at = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC) + oauth_response.expires_at = expires_at + oauth_response.metadata = {"name": "Workspace Bot", "avatar_url": "https://avatar.example.com/bot.png"} context = { "user_id": "user-1", @@ -125,7 +219,7 @@ def test_callback_success_new_credential(self, app: Flask): patch.object( DatasourceProviderService, "get_oauth_client", - return_value={"client_id": "abc"}, + return_value={"client_id": "abc", "client_secret": "secret"}, ), patch.object( OAuthHandler, @@ -136,11 +230,22 @@ def test_callback_success_new_credential(self, app: Flask): DatasourceProviderService, "add_datasource_oauth_provider", return_value=None, - ), + ) as add_oauth_provider, ): - response = method(api, "notion") + response = method(api, _PROVIDER_ID) assert response.status_code == 302 + assert "/oauth-callback" in response.location + add_oauth_provider.assert_called_once() + assert add_oauth_provider.call_args.kwargs == { + "tenant_id": "tenant-1", + "provider_id": add_oauth_provider.call_args.kwargs["provider_id"], + "avatar_url": "https://avatar.example.com/bot.png", + "name": "Workspace Bot", + "expire_at": expires_at, + "credentials": {"token": "abc"}, + } + assert str(add_oauth_provider.call_args.kwargs["provider_id"]) == _PROVIDER_ID def test_callback_missing_context(self, app: Flask): api = DatasourceOAuthCallback() @@ -223,12 +328,16 @@ def test_callback_reauthorize_existing_credential(self, app: Flask): DatasourceProviderService, "reauthorize_datasource_oauth_provider", return_value=None, - ), + ) as reauthorize_provider, ): - response = method(api, "notion") + response = method(api, _PROVIDER_ID) assert response.status_code == 302 assert "/oauth-callback" in response.location + reauthorize_provider.assert_called_once() + assert str(reauthorize_provider.call_args.kwargs["provider_id"]) == _PROVIDER_ID + assert reauthorize_provider.call_args.kwargs["credential_id"] == "cred-1" + assert reauthorize_provider.call_args.kwargs["credentials"] == {"token": "abc"} def test_callback_context_id_from_cookie(self, app: Flask): api = DatasourceOAuthCallback() @@ -278,7 +387,14 @@ def test_post_success(self, app: Flask): api = DatasourceAuth() method = inspect.unwrap(api.post) - payload = {"credentials": {"key": "val"}} + payload = { + "name": "Engineering Notion", + "credentials": { + "api_key": "secret-token", + "workspace": "engineering", + "database_id": "db-123", + }, + } with ( app.test_request_context("/", json=payload), @@ -287,11 +403,17 @@ def test_post_success(self, app: Flask): DatasourceProviderService, "add_datasource_api_key_provider", return_value=None, - ), + ) as add_api_key_provider, ): - response, status = method(api, "tenant-1", "notion") + response, status = method(api, "tenant-1", _PROVIDER_ID) + assert response == _success_response() assert status == 200 + add_api_key_provider.assert_called_once() + assert add_api_key_provider.call_args.kwargs["tenant_id"] == "tenant-1" + assert str(add_api_key_provider.call_args.kwargs["provider_id"]) == _PROVIDER_ID + assert add_api_key_provider.call_args.kwargs["credentials"] == payload["credentials"] + assert add_api_key_provider.call_args.kwargs["name"] == "Engineering Notion" def test_post_invalid_credentials(self, app: Flask): api = DatasourceAuth() @@ -321,19 +443,19 @@ def test_get_success(self, app: Flask): patch.object( DatasourceProviderService, "list_datasource_credentials", - return_value=[{"id": "1"}], + return_value=[_datasource_credential()], ), ): - response, status = method(api, "tenant-1", user, "notion") + response, status = method(api, "tenant-1", user, _PROVIDER_ID) assert status == 200 - assert response["result"] + assert response == {"result": [_datasource_credential()]} def test_post_missing_credentials(self, app: Flask): api = DatasourceAuth() method = inspect.unwrap(api.post) - payload = {} + payload: dict[str, object] = {} with ( app.test_request_context("/", json=payload), @@ -375,17 +497,24 @@ def test_delete_success(self, app: Flask): DatasourceProviderService, "remove_datasource_credentials", return_value=None, - ), + ) as remove_datasource_credentials, ): - response, status = method(api, "tenant-1", "notion") + response, status = method(api, "tenant-1", _PROVIDER_ID) + assert response == _success_response() assert status == 200 + remove_datasource_credentials.assert_called_once_with( + tenant_id="tenant-1", + auth_id="cred-1", + provider="notion", + plugin_id="langgenius/notion_datasource", + ) def test_delete_missing_credential_id(self, app: Flask): api = DatasourceAuthDeleteApi() method = inspect.unwrap(api.post) - payload = {} + payload: dict[str, object] = {} with ( app.test_request_context("/", json=payload), @@ -400,7 +529,11 @@ def test_update_success(self, app: Flask): api = DatasourceAuthUpdateApi() method = inspect.unwrap(api.post) - payload = {"credential_id": "id", "credentials": {"k": "v"}} + payload = { + "credential_id": "cred-1", + "name": "Updated Notion", + "credentials": {"api_key": "new-secret", "database_id": "db-456"}, + } with ( app.test_request_context("/", json=payload), @@ -409,11 +542,20 @@ def test_update_success(self, app: Flask): DatasourceProviderService, "update_datasource_credentials", return_value=None, - ), + ) as update_datasource_credentials, ): - response, status = method(api, "tenant-1", "notion") + response, status = method(api, "tenant-1", _PROVIDER_ID) + assert response == _success_response() assert status == 201 + update_datasource_credentials.assert_called_once_with( + tenant_id="tenant-1", + auth_id="cred-1", + provider="notion", + plugin_id="langgenius/notion_datasource", + credentials=payload["credentials"], + name="Updated Notion", + ) def test_update_with_credentials_none(self, app: Flask): api = DatasourceAuthUpdateApi() @@ -432,7 +574,9 @@ def test_update_with_credentials_none(self, app: Flask): ): response, status = method(api, "tenant-1", "notion") + assert response == _success_response() update_mock.assert_called_once() + assert update_mock.call_args.kwargs["credentials"] == {} assert status == 201 def test_update_name_only(self, app: Flask): @@ -450,8 +594,9 @@ def test_update_name_only(self, app: Flask): return_value=None, ), ): - _, status = method(api, "tenant-1", "notion") + response, status = method(api, "tenant-1", "notion") + assert response == _success_response() assert status == 201 def test_update_with_empty_credentials_dict(self, app: Flask): @@ -469,8 +614,9 @@ def test_update_with_empty_credentials_dict(self, app: Flask): return_value=None, ) as update_mock, ): - _, status = method(api, "tenant-1", "notion") + response, status = method(api, "tenant-1", "notion") + assert response == _success_response() update_mock.assert_called_once() assert status == 201 @@ -485,12 +631,14 @@ def test_list_success(self, app: Flask): patch.object( DatasourceProviderService, "get_all_datasource_credentials", - return_value=[{"id": "1"}], + return_value=[_datasource_auth()], ), ): response, status = method(api, "tenant-1") assert status == 200 + assert response == {"result": [_datasource_auth()]} + assert response == {"result": [_datasource_auth()]} def test_auth_list_empty(self, app: Flask): api = DatasourceAuthListApi() @@ -537,7 +685,7 @@ def test_list_success(self, app: Flask): patch.object( DatasourceProviderService, "get_hard_code_datasource_credentials", - return_value=[{"id": "1"}], + return_value=[_datasource_auth()], ), ): response, status = method(api, "tenant-1") @@ -550,7 +698,14 @@ def test_post_success(self, app: Flask): api = DatasourceAuthOauthCustomClient() method = inspect.unwrap(api.post) - payload = {"client_params": {}, "enable_oauth_custom_client": True} + payload = { + "client_params": { + "client_id": "custom-client", + "client_secret": "custom-secret", + "authorize_url": "https://auth.example.com/authorize", + }, + "enable_oauth_custom_client": True, + } with ( app.test_request_context("/", json=payload), @@ -559,11 +714,17 @@ def test_post_success(self, app: Flask): DatasourceProviderService, "setup_oauth_custom_client_params", return_value=None, - ), + ) as setup_custom_client, ): - response, status = method(api, "tenant-1", "notion") + response, status = method(api, "tenant-1", _PROVIDER_ID) + assert response == _success_response() assert status == 200 + setup_custom_client.assert_called_once() + assert setup_custom_client.call_args.kwargs["tenant_id"] == "tenant-1" + assert str(setup_custom_client.call_args.kwargs["datasource_provider_id"]) == _PROVIDER_ID + assert setup_custom_client.call_args.kwargs["client_params"] == payload["client_params"] + assert setup_custom_client.call_args.kwargs["enabled"] is True def test_delete_success(self, app: Flask): api = DatasourceAuthOauthCustomClient() @@ -575,17 +736,20 @@ def test_delete_success(self, app: Flask): DatasourceProviderService, "remove_oauth_custom_client_params", return_value=None, - ), + ) as remove_custom_client, ): - response, status = method(api, "tenant-1", "notion") + response, status = method(api, "tenant-1", _PROVIDER_ID) + assert response == _success_response() assert status == 200 + remove_custom_client.assert_called_once() + assert str(remove_custom_client.call_args.kwargs["datasource_provider_id"]) == _PROVIDER_ID def test_post_empty_payload(self, app: Flask): api = DatasourceAuthOauthCustomClient() method = inspect.unwrap(api.post) - payload = {} + payload: dict[str, object] = {} with ( app.test_request_context("/", json=payload), @@ -596,8 +760,9 @@ def test_post_empty_payload(self, app: Flask): return_value=None, ), ): - _, status = method(api, "tenant-1", "notion") + response, status = method(api, "tenant-1", "notion") + assert response == _success_response() assert status == 200 def test_post_disabled_flag(self, app: Flask): @@ -618,9 +783,12 @@ def test_post_disabled_flag(self, app: Flask): return_value=None, ) as setup_mock, ): - _, status = method(api, "tenant-1", "notion") + response, status = method(api, "tenant-1", "notion") + assert response == _success_response() setup_mock.assert_called_once() + assert setup_mock.call_args.kwargs["client_params"] == {"a": 1} + assert setup_mock.call_args.kwargs["enabled"] is False assert status == 200 @@ -638,17 +806,22 @@ def test_set_default_success(self, app: Flask): DatasourceProviderService, "set_default_datasource_provider", return_value=None, - ), + ) as set_default_datasource_provider, ): - response, status = method(api, "tenant-1", "notion") + response, status = method(api, "tenant-1", _PROVIDER_ID) + assert response == _success_response() assert status == 200 + set_default_datasource_provider.assert_called_once() + assert set_default_datasource_provider.call_args.kwargs["tenant_id"] == "tenant-1" + assert str(set_default_datasource_provider.call_args.kwargs["datasource_provider_id"]) == _PROVIDER_ID + assert set_default_datasource_provider.call_args.kwargs["credential_id"] == "cred-1" def test_default_missing_id(self, app: Flask): api = DatasourceAuthDefaultApi() method = inspect.unwrap(api.post) - payload = {} + payload: dict[str, object] = {} with ( app.test_request_context("/", json=payload), @@ -663,7 +836,7 @@ def test_update_name_success(self, app: Flask): api = DatasourceUpdateProviderNameApi() method = inspect.unwrap(api.post) - payload = {"credential_id": "id", "name": "New Name"} + payload = {"credential_id": "cred-1", "name": "New Name"} with ( app.test_request_context("/", json=payload), @@ -672,11 +845,17 @@ def test_update_name_success(self, app: Flask): DatasourceProviderService, "update_datasource_provider_name", return_value=None, - ), + ) as update_datasource_provider_name, ): - response, status = method(api, "tenant-1", "notion") + response, status = method(api, "tenant-1", _PROVIDER_ID) + assert response == _success_response() assert status == 200 + update_datasource_provider_name.assert_called_once() + assert update_datasource_provider_name.call_args.kwargs["tenant_id"] == "tenant-1" + assert str(update_datasource_provider_name.call_args.kwargs["datasource_provider_id"]) == _PROVIDER_ID + assert update_datasource_provider_name.call_args.kwargs["name"] == "New Name" + assert update_datasource_provider_name.call_args.kwargs["credential_id"] == "cred-1" def test_update_name_too_long(self, app: Flask): api = DatasourceUpdateProviderNameApi() diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py index 0d18120b7155f6..3ba1374d80c418 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py @@ -1,8 +1,11 @@ +import uuid from inspect import unwrap +from types import SimpleNamespace +from typing import Any from unittest.mock import MagicMock, patch import pytest -from flask import Flask, Response +from flask import Flask from controllers.common.errors import InvalidArgumentError, NotFoundError from controllers.console import console_ns @@ -16,8 +19,26 @@ RagPipelineVariableResetApi, ) from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID +from factories.variable_factory import build_segment from graphon.variables.types import SegmentType from models.account import Account, TenantAccountRole +from models.workflow import WorkflowDraftVariable +from services.workflow_draft_variable_service import WorkflowDraftVariableList + +_TEST_NODE_EXEC_ID = str(uuid.uuid4()) + + +def _node_variable(*, app_id: str = "p1", value: Any = "hello") -> WorkflowDraftVariable: + variable = WorkflowDraftVariable.new_node_variable( + app_id=app_id, + user_id="account-1", + node_id="node1", + name="node_var", + value=build_segment(value), + node_execution_id=_TEST_NODE_EXEC_ID, + ) + variable.id = str(uuid.uuid4()) + return variable @pytest.fixture @@ -47,13 +68,12 @@ def test_get_variables_success(self, app: Flask, fake_db, editor_user, restx_con method = unwrap(api.get) pipeline = MagicMock(id="p1") + variable = _node_variable(value="hello") rag_srv = MagicMock() rag_srv.is_workflow_exist.return_value = True - # IMPORTANT: RESTX expects .variables - var_list = MagicMock() - var_list.variables = [] + var_list = WorkflowDraftVariableList(variables=[variable], total=1) draft_srv = MagicMock() draft_srv.list_variables_without_values.return_value = var_list @@ -73,7 +93,22 @@ def test_get_variables_success(self, app: Flask, fake_db, editor_user, restx_con ): result = method(api, editor_user, pipeline) - assert result is var_list + assert result == { + "items": [ + { + "id": variable.id, + "type": "node", + "name": "node_var", + "description": "", + "selector": ["node1", "node_var"], + "value_type": "string", + "edited": False, + "visible": True, + "is_truncated": False, + } + ], + "total": 1, + } draft_srv.list_variables_without_values.assert_called_once_with( app_id="p1", page=1, @@ -114,8 +149,7 @@ def test_delete_variables_success(self, app: Flask, fake_db, editor_user): ): result = method(api, editor_user, pipeline) - assert isinstance(result, Response) - assert result.status_code == 204 + assert result == ("", 204) class TestRagPipelineNodeVariableCollectionApi: @@ -124,9 +158,8 @@ def test_get_node_variables_success(self, app: Flask, fake_db, editor_user, rest method = unwrap(api.get) pipeline = MagicMock(id="p1") - - var_list = MagicMock() - var_list.variables = [] + variable = _node_variable(value=None) + var_list = WorkflowDraftVariableList(variables=[variable]) srv = MagicMock() srv.list_node_variables.return_value = var_list @@ -142,7 +175,23 @@ def test_get_node_variables_success(self, app: Flask, fake_db, editor_user, rest ): result = method(api, editor_user, pipeline, "node1") - assert result is var_list + assert result == { + "items": [ + { + "id": variable.id, + "type": "node", + "name": "node_var", + "description": "", + "selector": ["node1", "node_var"], + "value_type": "none", + "edited": False, + "visible": True, + "is_truncated": False, + "value": None, + "full_content": None, + } + ] + } srv.list_node_variables.assert_called_once_with("p1", "node1", user_id="account-1") def test_get_node_variables_invalid_node(self, app: Flask, editor_user): @@ -157,6 +206,40 @@ def test_get_node_variables_invalid_node(self, app: Flask, editor_user): class TestRagPipelineVariableApi: + def test_get_variable_success_returns_concrete_shape(self, app: Flask, fake_db, editor_user): + api = RagPipelineVariableApi() + method = unwrap(api.get) + + pipeline = MagicMock(id="p1") + variable = _node_variable(value={"answer": 42}) + + srv = MagicMock() + srv.get_variable.return_value = variable + + with ( + app.test_request_context("/"), + patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", fake_db), + patch( + "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService", + return_value=srv, + ), + ): + result = method(api, editor_user, pipeline, variable.id) + + assert result == { + "id": variable.id, + "type": "node", + "name": "node_var", + "description": "", + "selector": ["node1", "node_var"], + "value_type": "object", + "edited": False, + "visible": True, + "is_truncated": False, + "value": {"answer": 42}, + "full_content": None, + } + def test_get_variable_not_found(self, app: Flask, fake_db, editor_user): api = RagPipelineVariableApi() method = unwrap(api.get) @@ -199,6 +282,42 @@ def test_patch_variable_invalid_file_payload(self, app: Flask, fake_db, editor_u with pytest.raises(InvalidArgumentError): method(api, editor_user, pipeline, "v1") + def test_patch_variable_noop_returns_current_concrete_shape(self, app: Flask, fake_db, editor_user): + api = RagPipelineVariableApi() + method = unwrap(api.patch) + + pipeline = MagicMock(id="p1", tenant_id="t1") + variable = _node_variable(value=42) + + srv = MagicMock() + srv.get_variable.return_value = variable + + with ( + app.test_request_context("/", json={}), + patch.object(type(console_ns), "payload", {}), + patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", fake_db), + patch( + "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService", + return_value=srv, + ), + ): + result = method(api, editor_user, pipeline, variable.id) + + assert result == { + "id": variable.id, + "type": "node", + "name": "node_var", + "description": "", + "selector": ["node1", "node_var"], + "value_type": "number", + "edited": False, + "visible": True, + "is_truncated": False, + "value": 42, + "full_content": None, + } + srv.update_variable.assert_not_called() + def test_delete_variable_success(self, app: Flask, fake_db, editor_user): api = RagPipelineVariableApi() method = unwrap(api.delete) @@ -219,7 +338,7 @@ def test_delete_variable_success(self, app: Flask, fake_db, editor_user): ): result = method(api, editor_user, pipeline, "v1") - assert result.status_code == 204 + assert result == ("", 204) class TestRagPipelineVariableResetApi: @@ -229,7 +348,7 @@ def test_reset_variable_success(self, app: Flask, fake_db, editor_user): pipeline = MagicMock(id="p1") workflow = MagicMock() - variable = MagicMock(app_id="p1") + variable = _node_variable(value="reset") srv = MagicMock() srv.get_variable.return_value = variable @@ -249,14 +368,22 @@ def test_reset_variable_success(self, app: Flask, fake_db, editor_user): "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService", return_value=srv, ), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.marshal", - return_value={"id": "v1"}, - ), ): - result = method(api, editor_user, pipeline, "v1") - - assert result == {"id": "v1"} + result = method(api, editor_user, pipeline, variable.id) + + assert result == { + "id": variable.id, + "type": "node", + "name": "node_var", + "description": "", + "selector": ["node1", "node_var"], + "value_type": "string", + "edited": False, + "visible": True, + "is_truncated": False, + "value": "reset", + "full_content": None, + } class TestSystemAndEnvironmentVariablesApi: @@ -265,9 +392,16 @@ def test_system_variables_success(self, app: Flask, fake_db, editor_user, restx_ method = unwrap(api.get) pipeline = MagicMock(id="p1") - - var_list = MagicMock() - var_list.variables = [] + variable = WorkflowDraftVariable.new_sys_variable( + app_id="p1", + user_id="account-1", + name="query", + value=build_segment("system query"), + editable=True, + node_execution_id=_TEST_NODE_EXEC_ID, + ) + variable.id = str(uuid.uuid4()) + var_list = WorkflowDraftVariableList(variables=[variable]) srv = MagicMock() srv.list_system_variables.return_value = var_list @@ -283,19 +417,35 @@ def test_system_variables_success(self, app: Flask, fake_db, editor_user, restx_ ): result = method(api, editor_user, pipeline) - assert result is var_list + assert result == { + "items": [ + { + "id": variable.id, + "type": "sys", + "name": "query", + "description": "", + "selector": ["sys", "query"], + "value_type": "string", + "edited": False, + "visible": True, + "is_truncated": False, + "value": "system query", + "full_content": None, + } + ] + } srv.list_system_variables.assert_called_once_with("p1", user_id="account-1") def test_environment_variables_success(self, app: Flask, editor_user): api = RagPipelineEnvironmentVariableCollectionApi() method = unwrap(api.get) - env_var = MagicMock( + env_var = SimpleNamespace( id="e1", name="ENV", description="d", - selector="s", - value_type=MagicMock(value="string"), + selector=["env", "ENV"], + value_type=SimpleNamespace(value="string"), value="x", ) @@ -314,4 +464,19 @@ def test_environment_variables_success(self, app: Flask, editor_user): ): result = method(api, editor_user, pipeline) - assert len(result["items"]) == 1 + assert result == { + "items": [ + { + "id": "e1", + "type": "env", + "name": "ENV", + "description": "d", + "selector": ["env", "ENV"], + "value_type": "string", + "value": "x", + "edited": False, + "visible": True, + "editable": True, + } + ] + } diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py index 5cc5af9592ba96..19dc90ed8a4178 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py @@ -158,3 +158,65 @@ def begin(self): assert response["id"] == "workflow-1" assert response["marked_name"] == "Updated release" assert response["hash"] == "hash-1" + + +def test_default_rag_pipeline_block_configs_serializes_root_response(monkeypatch: pytest.MonkeyPatch) -> None: + block_configs = [{"type": "start", "config": {"title": "Start"}}] + monkeypatch.setattr( + module, + "RagPipelineService", + lambda: SimpleNamespace(get_default_block_configs=lambda: block_configs), + ) + + api = module.DefaultRagPipelineBlockConfigsApi() + handler = unwrap_all(api.get) + + response = handler(api, _pipeline()) + + assert response == block_configs + + +def test_draft_rag_pipeline_second_step_parameters_serializes_variables(app, monkeypatch: pytest.MonkeyPatch) -> None: + variables = [ + { + "belong_to_node_id": "shared", + "type": "number", + "label": "Chunk size", + "variable": "chunk_size", + "default_value": 1024, + "required": True, + } + ] + monkeypatch.setattr( + module, + "RagPipelineService", + lambda: SimpleNamespace(get_second_step_parameters=lambda **_kwargs: variables), + ) + + api = module.DraftRagPipelineSecondStepApi() + handler = unwrap_all(api.get) + + with app.test_request_context("/?node_id=node-1"): + response = handler(api, _pipeline()) + + assert response["variables"] == variables + + +def test_rag_pipeline_recommended_plugins_serializes_known_envelope(app, monkeypatch: pytest.MonkeyPatch) -> None: + recommended_plugins = { + "installed_recommended_plugins": [{"name": "Dify Extractor", "meta": {"version": "1.0.0"}}], + "uninstalled_recommended_plugins": [{"plugin_id": "langgenius/notion_datasource"}], + } + monkeypatch.setattr( + module, + "RagPipelineService", + lambda: SimpleNamespace(get_recommended_plugins=lambda *_args: recommended_plugins), + ) + + api = module.RagPipelineRecommendedPluginApi() + handler = unwrap_all(api.get) + + with app.test_request_context("/?type=tool"): + response = handler(api, "tenant-1", _account()) + + assert response == recommended_plugins diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index 76a0955898754e..57388c104171ce 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -31,6 +31,7 @@ DatasetUseCheckApi, ) from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError +from core.entities.knowledge_entities import IndexingEstimate from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError from core.provider_manager import ProviderManager from core.rag.index_processor.constant.index_type import IndexStructureType @@ -1379,8 +1380,7 @@ def test_post_success_upload_file(self, app: Flask): mock_file = self._upload_file() - mock_response = MagicMock() - mock_response.model_dump.return_value = {"tokens": 100} + mock_response = IndexingEstimate(total_segments=100, preview=[]) with ( app.test_request_context("/"), @@ -1406,7 +1406,13 @@ def test_post_success_upload_file(self, app: Flask): response, status = method(api, "tenant-1") assert status == 200 - assert response == {"tokens": 100} + assert response == { + "tokens": 0, + "total_price": 0, + "currency": "USD", + "total_segments": 100, + "preview": [], + } def test_post_file_not_found(self, app: Flask): api = DatasetIndexingEstimateApi() diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py index 0b4ce39bafbef1..f4e240f41b86f7 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py @@ -1,3 +1,4 @@ +import datetime import inspect from unittest.mock import MagicMock, patch @@ -34,6 +35,7 @@ InvalidActionError, InvalidMetadataError, ) +from core.entities.knowledge_entities import IndexingEstimate from core.rag.index_processor.constant.index_type import IndexStructureType from models.dataset import Dataset from models.dataset import Document as DatasetDocument @@ -77,6 +79,46 @@ def make_serializable_document(**overrides): return document +def make_document_detail(**overrides): + attrs = { + "id": "doc-1", + "position": 1, + "data_source_type": "upload_file", + "data_source_info_dict": {"upload_file_id": "file-1"}, + "data_source_detail_dict": {}, + "dataset_process_rule_id": None, + "dataset_process_rule": None, + "name": "Document", + "created_from": "web", + "created_by": "u1", + "created_at": datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC), + "tokens": 10, + "indexing_status": "completed", + "completed_at": None, + "updated_at": None, + "indexing_latency": None, + "error": None, + "enabled": True, + "disabled_at": None, + "disabled_by": None, + "archived": False, + "doc_type": "others", + "doc_metadata_details": [], + "segment_count": 0, + "average_segment_length": 0, + "hit_count": 0, + "display_status": "available", + "doc_form": "text_model", + "doc_language": "English", + "need_summary": False, + } + attrs.update(overrides) + document = MagicMock(spec_set=list(attrs)) + for name, value in attrs.items(): + setattr(document, name, value) + return document + + def make_dataset(**overrides): attrs = { "id": "ds-1", @@ -172,6 +214,42 @@ def test_get_default_success(self, app: Flask, patch_tenant): assert "rules" in response + def test_get_with_document_preserves_legacy_segmentation_delimiter(self, app: Flask, patch_tenant): + api = GetProcessRuleApi() + method = inspect.unwrap(api.get) + user, _ = patch_tenant + + document = MagicMock(dataset_id="ds-1") + process_rule = MagicMock( + mode="custom", + rules_dict={"segmentation": {"delimiter": "---", "max_tokens": 123}}, + ) + + with ( + app.test_request_context("/?document_id=doc-1"), + patch( + "controllers.console.datasets.datasets_document.db.get_or_404", + return_value=document, + ), + patch( + "controllers.console.datasets.datasets_document.DatasetService.get_dataset", + return_value=MagicMock(), + ), + patch( + "controllers.console.datasets.datasets_document.DatasetService.check_dataset_permission", + return_value=None, + ), + patch( + "controllers.console.datasets.datasets_document.db.session.scalar", + return_value=process_rule, + ), + ): + response = method(api, user) + + assert response["rules"]["segmentation"]["separator"] == "---" + assert response["rules"]["segmentation"]["max_tokens"] == 123 + assert "delimiter" not in response["rules"]["segmentation"] + def test_get_with_document_dataset_not_found(self, app: Flask, patch_tenant): api = GetProcessRuleApi() method = inspect.unwrap(api.get) @@ -413,7 +491,7 @@ def test_get_success(self, app: Flask, patch_tenant): method = inspect.unwrap(api.get) user, tenant_id = patch_tenant - document = MagicMock(dataset_process_rule=None) + document = make_document_detail() with ( app.test_request_context("/"), @@ -925,12 +1003,24 @@ def test_get_success(self, app: Flask, patch_tenant, patch_permission): ), patch( "services.summary_index_service.SummaryIndexService.get_document_summary_status_detail", - return_value={"total_segments": 0}, + return_value={ + "total_segments": 1, + "summary_status": {"timeout": 1}, + "summaries": [ + { + "segment_id": "segment-1", + "segment_position": 1, + "status": "timeout", + } + ], + }, ), ): response, status = method(api, user, "ds-1", "doc-1") assert status == 200 + assert response["summary_status"]["timeout"] == 1 + assert response["summaries"][0]["status"] == "timeout" class TestDocumentIndexingEstimateApi: @@ -1102,7 +1192,7 @@ def test_batch_indexing_estimate_website(self, app: Flask, patch_tenant): patch.object(api, "get_batch_documents", return_value=[doc]), patch( "controllers.console.datasets.datasets_document.IndexingRunner.indexing_estimate", - return_value=MagicMock(model_dump=lambda: {"tokens": 2}), + return_value=IndexingEstimate(total_segments=2, preview=[]), ), ): resp, status = method(api, tenant_id, user, "ds-1", "batch-1") @@ -1131,7 +1221,7 @@ def test_batch_indexing_estimate_notion(self, app: Flask, patch_tenant): patch.object(api, "get_batch_documents", return_value=[doc]), patch( "controllers.console.datasets.datasets_document.IndexingRunner.indexing_estimate", - return_value=MagicMock(model_dump=lambda: {"tokens": 1}), + return_value=IndexingEstimate(total_segments=1, preview=[]), ), ): resp, status = method(api, tenant_id, user, "ds-1", "batch-1") @@ -1313,7 +1403,7 @@ def test_get_with_only_option(self, app: Flask, patch_tenant): method = inspect.unwrap(api.get) user, tenant_id = patch_tenant - document = MagicMock(dataset_process_rule=None, doc_metadata_details=[]) + document = make_document_detail(doc_metadata_details=[]) with ( app.test_request_context("/?metadata=only"), @@ -1333,7 +1423,7 @@ def test_get_with_without_option(self, app: Flask, patch_tenant): method = inspect.unwrap(api.get) user, tenant_id = patch_tenant - document = MagicMock(dataset_process_rule=None) + document = make_document_detail() with ( app.test_request_context("/?metadata=without"), @@ -1610,7 +1700,7 @@ def test_document_indexing_with_extraction_setting(self, app: Flask, patch_tenan ), patch( "controllers.console.datasets.datasets_document.IndexingRunner.indexing_estimate", - return_value=MagicMock(model_dump=lambda: {"tokens": 5}), + return_value=IndexingEstimate(total_segments=5, preview=[]), ), ): response, status = method(api, tenant_id, user, "ds-1", "doc-1") diff --git a/api/tests/unit_tests/controllers/console/datasets/test_external.py b/api/tests/unit_tests/controllers/console/datasets/test_external.py index b7e16b91fb752f..12671458f70ed0 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_external.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_external.py @@ -1,4 +1,6 @@ import inspect +from types import SimpleNamespace +from typing import Any from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -16,6 +18,7 @@ ExternalDatasetCreateApi, ExternalKnowledgeHitTestingApi, ) +from extensions.ext_database import db from models.account import Account, TenantAccountRole from services.dataset_service import DatasetService from services.external_knowledge_service import ExternalDatasetService @@ -38,28 +41,183 @@ def current_user() -> Account: return user +def _external_api_dict(api_id: str = "api-1") -> dict: + return { + "id": api_id, + "tenant_id": "tenant-1", + "name": f"External API {api_id}", + "description": f"Description for {api_id}", + "settings": { + "endpoint": f"https://external.example.com/{api_id}", + "api_key": "secret", + "headers": {"X-Source": "unit-test"}, + "timeout": 30, + }, + "dataset_bindings": [ + {"id": f"dataset-{api_id}", "name": f"Dataset {api_id}"}, + ], + "created_by": "user-1", + "created_at": "2024-01-01T00:00:00", + } + + +def _external_api_object(api_id: str = "api-1") -> SimpleNamespace: + payload = _external_api_dict(api_id) + return SimpleNamespace( + **{ + **payload, + "dataset_bindings": [SimpleNamespace(**binding) for binding in payload["dataset_bindings"]], + } + ) + + +def _expected_dataset_detail_payload() -> dict[str, Any]: + return { + "id": "dataset-1", + "name": "Support knowledge", + "description": "External support articles", + "provider": "external", + "permission": "only_me", + "data_source_type": "external", + "indexing_technique": "economy", + "app_count": 2, + "document_count": 7, + "word_count": 2048, + "created_by": "user-1", + "author_name": "Test User", + "created_at": 1710000000, + "updated_by": "user-2", + "updated_at": 1710003600, + "embedding_model": None, + "embedding_model_provider": None, + "embedding_available": False, + "retrieval_model_dict": { + "search_method": "semantic_search", + "reranking_enable": False, + "reranking_mode": None, + "reranking_model": {"reranking_provider_name": None, "reranking_model_name": None}, + "weights": None, + "top_k": 4, + "score_threshold_enabled": True, + "score_threshold": 0.5, + }, + "summary_index_setting": { + "enable": True, + "model_name": "summary-model", + "model_provider_name": "provider-a", + "summary_prompt": "Summarize this.", + }, + "tags": [{"id": "tag-1", "name": "Support", "type": "knowledge"}], + "doc_form": "text_model", + "external_knowledge_info": { + "external_knowledge_id": "knowledge-1", + "external_knowledge_api_id": "api-1", + "external_knowledge_api_name": "External API api-1", + "external_knowledge_api_endpoint": "https://external.example.com/api-1", + }, + "external_retrieval_model": { + "top_k": 4, + "score_threshold": 0.5, + "score_threshold_enabled": True, + }, + "doc_metadata": [{"id": "metadata-1", "name": "source", "type": "string"}], + "built_in_field_enabled": True, + "pipeline_id": None, + "runtime_mode": "external", + "chunk_structure": "general", + "icon_info": { + "icon_type": "emoji", + "icon": "book", + "icon_background": "#FFF4ED", + "icon_url": None, + }, + "is_published": True, + "total_documents": 7, + "total_available_documents": 6, + "enable_api": True, + "is_multimodal": False, + "maintainer": None, + "permission_keys": [], + } + + +def _dataset_detail_object() -> SimpleNamespace: + payload = _expected_dataset_detail_payload() + return SimpleNamespace( + **{ + **payload, + "summary_index_setting": SimpleNamespace(**payload["summary_index_setting"]), + "tags": [SimpleNamespace(**tag) for tag in payload["tags"]], + "external_knowledge_info": SimpleNamespace(**payload["external_knowledge_info"]), + "external_retrieval_model": SimpleNamespace(**payload["external_retrieval_model"]), + "doc_metadata": [SimpleNamespace(**item) for item in payload["doc_metadata"]], + "icon_info": SimpleNamespace(**payload["icon_info"]), + } + ) + + class TestExternalApiTemplateListApi: def test_get_success(self, app: Flask): api = ExternalApiTemplateListApi() method = inspect.unwrap(api.get) - api_item = MagicMock() - api_item.to_dict.return_value = {"id": "1"} + api_item = _external_api_object("api-1") with ( - app.test_request_context("/?page=1&limit=20"), + app.test_request_context("/?page=2&limit=1&keyword=vector"), patch.object( ExternalDatasetService, "get_external_knowledge_apis", - return_value=([api_item], 1), + return_value=([api_item], 3), ) as get_external_knowledge_apis, ): resp, status = method(api, "tenant-1") assert status == 200 - assert resp["total"] == 1 - assert resp["data"][0]["id"] == "1" - get_external_knowledge_apis.assert_called_once_with(1, 20, "tenant-1", None) + assert resp == { + "data": [_external_api_dict("api-1")], + "has_more": True, + "limit": 1, + "total": 3, + "page": 2, + } + get_external_knowledge_apis.assert_called_once_with(2, 1, "tenant-1", "vector") + + def test_post_success_uses_validated_payload_and_returns_template(self, app: Flask, current_user: Account): + api = ExternalApiTemplateListApi() + method = inspect.unwrap(api.post) + + payload = { + "name": "Vendor Search", + "settings": { + "endpoint": "https://external.example.com/search", + "api_key": "secret", + "headers": {"X-Source": "unit-test"}, + "timeout": 30, + }, + } + created = _external_api_object("api-created") + + with ( + app.test_request_context("/", json=payload), + patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), + patch.object(ExternalDatasetService, "validate_api_list") as validate_api_list, + patch.object( + ExternalDatasetService, + "create_external_knowledge_api", + return_value=created, + ) as create_external_knowledge_api, + ): + resp, status = method(api, "tenant-1", current_user) + + assert status == 201 + assert resp == _external_api_dict("api-created") + validate_api_list.assert_called_once_with(payload["settings"]) + create_external_knowledge_api.assert_called_once_with( + tenant_id="tenant-1", + user_id="user-1", + args=payload, + ) def test_post_forbidden(self, app: Flask, current_user: Account): current_user.role = TenantAccountRole.NORMAL @@ -97,6 +255,25 @@ def test_post_duplicate_name(self, app: Flask, current_user: Account): class TestExternalApiTemplateApi: + def test_get_success_returns_template_contract(self, app: Flask): + api = ExternalApiTemplateApi() + method = inspect.unwrap(api.get) + template = _external_api_object("api-detail") + + with ( + app.test_request_context("/"), + patch.object( + ExternalDatasetService, + "get_external_knowledge_api", + return_value=template, + ) as get_external_knowledge_api, + ): + resp, status = method(api, "tenant-1", "api-detail") + + assert status == 200 + assert resp == _external_api_dict("api-detail") + get_external_knowledge_api.assert_called_once_with("api-detail", "tenant-1") + def test_get_not_found(self, app: Flask): api = ExternalApiTemplateApi() method = inspect.unwrap(api.get) @@ -112,6 +289,42 @@ def test_get_not_found(self, app: Flask): with pytest.raises(NotFound): method(api, "tenant-1", "api-id") + def test_patch_success_uses_validated_payload_and_returns_template(self, app: Flask, current_user: Account): + api = ExternalApiTemplateApi() + method = inspect.unwrap(api.patch) + + payload = { + "name": "Updated API", + "settings": { + "endpoint": "https://external.example.com/updated", + "api_key": "new-secret", + "headers": {"X-Version": "2"}, + }, + } + updated = _external_api_object("api-updated") + + with ( + app.test_request_context("/", json=payload), + patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), + patch.object(ExternalDatasetService, "validate_api_list") as validate_api_list, + patch.object( + ExternalDatasetService, + "update_external_knowledge_api", + return_value=updated, + ) as update_external_knowledge_api, + ): + resp, status = method(api, "tenant-1", current_user, "api-updated") + + assert status == 200 + assert resp == _external_api_dict("api-updated") + validate_api_list.assert_called_once_with(payload["settings"]) + update_external_knowledge_api.assert_called_once_with( + tenant_id="tenant-1", + user_id="user-1", + external_knowledge_api_id="api-updated", + args=payload, + ) + def test_delete_forbidden(self, app: Flask, current_user: Account): current_user.role = TenantAccountRole.NORMAL @@ -149,45 +362,37 @@ def test_create_success(self, app: Flask, current_user: Account): method = inspect.unwrap(api.post) payload = { - "external_knowledge_api_id": "api", - "external_knowledge_id": "kid", - "name": "dataset", + "external_knowledge_api_id": "api-1", + "external_knowledge_id": "knowledge-1", + "name": "Support knowledge", + "description": "External support articles", + "external_retrieval_model": { + "top_k": 4, + "score_threshold": 0.5, + "score_threshold_enabled": True, + }, } - dataset = MagicMock() - - dataset.embedding_available = False - dataset.built_in_field_enabled = False - dataset.is_published = False - dataset.enable_api = False - dataset.enable_qa = False - dataset.enable_vector_store = False - dataset.vector_store_setting = None - dataset.is_multimodal = False - - dataset.retrieval_model_dict = {} - dataset.tags = [] - dataset.external_knowledge_info = None - dataset.external_retrieval_model = None - dataset.doc_metadata = [] - dataset.icon_info = None - dataset.permission_keys = [] - - dataset.summary_index_setting = MagicMock() - dataset.summary_index_setting.enable = False + dataset = _dataset_detail_object() with ( - app.test_request_context("/"), + app.test_request_context("/", json=payload), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), patch.object( ExternalDatasetService, "create_external_dataset", return_value=dataset, - ), + ) as create_external_dataset, ): - _, status = method(api, "tenant-1", current_user) + resp, status = method(api, "tenant-1", current_user) assert status == 201 + assert resp == _expected_dataset_detail_payload() + create_external_dataset.assert_called_once_with( + tenant_id="tenant-1", + user_id="user-1", + args=payload, + ) def test_create_forbidden(self, app: Flask, current_user: Account): current_user.role = TenantAccountRole.NORMAL @@ -228,24 +433,58 @@ def test_hit_testing_success(self, app: Flask, current_user: Account): api = ExternalKnowledgeHitTestingApi() method = inspect.unwrap(api.post) - payload = {"query": "hello"} + payload = { + "query": "hello", + "external_retrieval_model": { + "top_k": 3, + "score_threshold": 0.25, + "score_threshold_enabled": True, + }, + "metadata_filtering_conditions": { + "logical_operator": "and", + "conditions": [{"name": "source", "comparison_operator": "contains", "value": "external"}], + }, + } dataset = MagicMock() + retrieve_response = { + "query": {"content": "hello"}, + "records": [ + { + "content": "answer", + "title": "doc", + "score": 0.9, + "metadata": {"source": "external", "page": 2}, + } + ], + } with ( - app.test_request_context("/"), + app.test_request_context("/", json=payload), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), patch.object(DatasetService, "get_dataset", return_value=dataset), - patch.object(DatasetService, "check_dataset_permission"), + patch.object(DatasetService, "check_dataset_permission") as check_dataset_permission, + patch.object(HitTestingService, "hit_testing_args_check") as hit_testing_args_check, patch.object( HitTestingService, "external_retrieve", - return_value={"ok": True}, - ), + return_value=retrieve_response, + ) as external_retrieve, + patch("controllers.console.datasets.external.dump_response", side_effect=lambda _model, value: value), ): resp = method(api, current_user, "dataset-id") - assert resp["ok"] is True + assert resp == retrieve_response + check_dataset_permission.assert_called_once_with(dataset, current_user) + hit_testing_args_check.assert_called_once_with(payload) + external_retrieve.assert_called_once_with( + session=db.session, + dataset=dataset, + query="hello", + account=current_user, + external_retrieval_model=payload["external_retrieval_model"], + metadata_filtering_conditions=payload["metadata_filtering_conditions"], + ) class TestBedrockRetrievalApi: @@ -254,24 +493,44 @@ def test_bedrock_retrieval(self, app: Flask): method = inspect.unwrap(api.post) payload = { - "retrieval_setting": {}, - "query": "hello", - "knowledge_id": "kid", + "retrieval_setting": {"top_k": 5, "score_threshold": 0.72}, + "query": "hello bedrock", + "knowledge_id": "knowledge-base-1", + } + retrieval_response = { + "records": [ + { + "metadata": {"source": "bedrock", "uri": "s3://bucket/doc.txt"}, + "score": 0.8, + "title": "doc", + "content": "answer", + }, + { + "metadata": {"source": "bedrock", "uri": "s3://bucket/other.txt"}, + "score": 0.65, + "title": None, + "content": None, + }, + ] } with ( - app.test_request_context("/"), + app.test_request_context("/", json=payload), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), patch.object( ExternalDatasetTestService, "knowledge_retrieval", - return_value={"ok": True}, - ), + return_value=retrieval_response, + ) as knowledge_retrieval, ): resp, status = method() assert status == 200 - assert resp["ok"] is True + assert resp == retrieval_response + retrieval_setting, query, knowledge_id = knowledge_retrieval.call_args.args + assert retrieval_setting.model_dump() == payload["retrieval_setting"] + assert query == "hello bedrock" + assert knowledge_id == "knowledge-base-1" class TestExternalApiTemplateListApiAdvanced: @@ -297,10 +556,10 @@ def test_get_with_pagination(self, app: Flask): api = ExternalApiTemplateListApi() method = inspect.unwrap(api.get) - templates = [MagicMock(id=f"api-{i}") for i in range(3)] + templates = [_external_api_object(f"api-{i}") for i in range(3)] with ( - app.test_request_context("/?page=1&limit=20"), + app.test_request_context("/?page=2&limit=3"), patch( "controllers.console.datasets.external.ExternalDatasetService.get_external_knowledge_apis", return_value=(templates, 25), @@ -309,9 +568,14 @@ def test_get_with_pagination(self, app: Flask): resp, status = method(api, "tenant-1") assert status == 200 - assert resp["total"] == 25 - assert len(resp["data"]) == 3 - get_external_knowledge_apis.assert_called_once_with(1, 20, "tenant-1", None) + assert resp == { + "data": [_external_api_dict(f"api-{i}") for i in range(3)], + "has_more": True, + "limit": 3, + "total": 25, + "page": 2, + } + get_external_knowledge_apis.assert_called_once_with(2, 3, "tenant-1", None) class TestExternalDatasetCreateApiAdvanced: @@ -374,15 +638,46 @@ def test_hit_testing_with_custom_retrieval_model(self, app: Flask, current_user: "controllers.console.datasets.external.DatasetService.get_dataset", return_value=dataset, ), - patch("controllers.console.datasets.external.DatasetService.check_dataset_permission"), + patch("controllers.console.datasets.external.DatasetService.check_dataset_permission") as check_permission, + patch("controllers.console.datasets.external.HitTestingService.hit_testing_args_check") as args_check, patch( "controllers.console.datasets.external.HitTestingService.external_retrieve", - return_value={"results": []}, - ), + return_value={ + "query": {"content": "test query"}, + "records": [ + { + "content": None, + "title": "metadata-only", + "score": None, + "metadata": {"status": "active"}, + } + ], + }, + ) as external_retrieve, ): resp = method(api, current_user, "ds-1") - assert resp["results"] == [] + assert resp == { + "query": {"content": "test query"}, + "records": [ + { + "content": None, + "title": "metadata-only", + "score": None, + "metadata": {"status": "active"}, + } + ], + } + check_permission.assert_called_once_with(dataset, current_user) + args_check.assert_called_once_with(payload) + external_retrieve.assert_called_once_with( + session=db.session, + dataset=dataset, + query="test query", + account=current_user, + external_retrieval_model={"type": "bm25"}, + metadata_filtering_conditions={"status": "active"}, + ) class TestBedrockRetrievalApiAdvanced: diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial.py b/api/tests/unit_tests/controllers/console/explore/test_trial.py index be68a3beed6d4e..2ac9fc978d8223 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_trial.py +++ b/api/tests/unit_tests/controllers/console/explore/test_trial.py @@ -1,4 +1,4 @@ -from inspect import unwrap as inspect_unwrap +from inspect import unwrap from io import BytesIO from typing import Any from unittest.mock import MagicMock, patch @@ -35,24 +35,16 @@ from services.errors.conversation import ConversationNotExistsError from services.errors.llm import InvokeRateLimitError -unwrap: Any = inspect_unwrap - @pytest.fixture -def account() -> Account: - acc = Account(name="User", email="user@example.com") +def account(): + acc = MagicMock(spec=Account) acc.id = "u1" return acc -def _file_data() -> Any: - file_data: Any = BytesIO(b"fake audio data") - file_data.filename = "test.wav" - return file_data - - @pytest.fixture -def trial_app_chat() -> MagicMock: +def trial_app_chat(): app = MagicMock() app.id = "a-chat" app.mode = AppMode.CHAT @@ -60,7 +52,7 @@ def trial_app_chat() -> MagicMock: @pytest.fixture -def trial_app_completion() -> MagicMock: +def trial_app_completion(): app = MagicMock() app.id = "a-comp" app.mode = AppMode.COMPLETION @@ -68,7 +60,7 @@ def trial_app_completion() -> MagicMock: @pytest.fixture -def trial_app_workflow() -> MagicMock: +def trial_app_workflow(): app = MagicMock() app.id = "a-workflow" app.mode = AppMode.WORKFLOW @@ -76,7 +68,7 @@ def trial_app_workflow() -> MagicMock: @pytest.fixture -def valid_parameters() -> dict[str, object]: +def valid_parameters(): return { "user_input_form": [], "system_parameters": {}, @@ -92,13 +84,54 @@ def valid_parameters() -> dict[str, object]: } -def test_trial_workflow_uses_trial_scoped_simple_account_model() -> None: - assert module.simple_account_model.name == "TrialSimpleAccount" - assert hasattr(module.simple_account_model, "items") +def test_trial_workflow_registers_normalized_simple_account_response_model(): + assert "SimpleAccountResponse" in module.console_ns.models + + +def _response_model_name(entry: object) -> str: + assert isinstance(entry, tuple) + assert len(entry) >= 2 + model = entry[1] + name = getattr(model, "name", None) + assert isinstance(name, str) + return name + + +def test_trial_endpoints_keep_response_and_query_docs(): + untyped_generated_response_views = [ + module.TrialAppWorkflowRunApi.post, + module.TrialChatApi.post, + module.TrialCompletionApi.post, + ] + for view in untyped_generated_response_views: + apidoc = getattr(view, "__apidoc__", {}) + assert apidoc.get("responses", {})["200"] == ("Success", None, {}) + + cases = [ + (module.TrialMessageSuggestedQuestionApi.get, module.SuggestedQuestionsResponse.__name__), + (module.TrialChatAudioApi.post, module.AudioTranscriptResponse.__name__), + (module.TrialChatTextApi.post, module.AudioBinaryResponse.__name__), + (module.TrialSitApi.get, module.SiteResponse.__name__), + (module.TrialAppParameterApi.get, module.ParametersResponse.__name__), + (module.AppApi.get, module.AppDetailWithSite.__name__), + (module.AppWorkflowApi.get, module.WorkflowResponse.__name__), + (module.DatasetListApi.get, module.TrialDatasetListResponse.__name__), + ] + + for view, model_name in cases: + apidoc = getattr(view, "__apidoc__", {}) + responses = apidoc.get("responses", {}) + assert _response_model_name(responses["200"]) == model_name + + dataset_params = module.DatasetListApi.get.__apidoc__["params"] + assert dataset_params["ids"]["in"] == "query" + assert dataset_params["ids"]["type"] == "array" + assert dataset_params["page"]["default"] == 1 + assert dataset_params["limit"]["default"] == 20 class TestTrialAppWorkflowRunApi: - def test_not_workflow_app(self, app: Flask, account: Account) -> None: + def test_not_workflow_app(self, app: Flask, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -106,7 +139,7 @@ def test_not_workflow_app(self, app: Flask, account: Account) -> None: with pytest.raises(NotWorkflowAppError): method(api, account, MagicMock(mode=AppMode.CHAT)) - def test_success(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: + def test_success(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -119,7 +152,7 @@ def test_success(self, app: Flask, trial_app_workflow: MagicMock, account: Accou assert result is not None - def test_workflow_provider_not_init(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: + def test_workflow_provider_not_init(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -134,7 +167,7 @@ def test_workflow_provider_not_init(self, app: Flask, trial_app_workflow: MagicM with pytest.raises(ProviderNotInitializeError): method(api, account, trial_app_workflow) - def test_workflow_quota_exceeded(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: + def test_workflow_quota_exceeded(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -149,7 +182,7 @@ def test_workflow_quota_exceeded(self, app: Flask, trial_app_workflow: MagicMock with pytest.raises(ProviderQuotaExceededError): method(api, account, trial_app_workflow) - def test_workflow_model_not_support(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: + def test_workflow_model_not_support(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -164,7 +197,7 @@ def test_workflow_model_not_support(self, app: Flask, trial_app_workflow: MagicM with pytest.raises(ProviderModelCurrentlyNotSupportError): method(api, account, trial_app_workflow) - def test_workflow_invoke_error(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: + def test_workflow_invoke_error(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -179,7 +212,7 @@ def test_workflow_invoke_error(self, app: Flask, trial_app_workflow: MagicMock, with pytest.raises(CompletionRequestError): method(api, account, trial_app_workflow) - def test_workflow_rate_limit_error(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: + def test_workflow_rate_limit_error(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -194,7 +227,7 @@ def test_workflow_rate_limit_error(self, app: Flask, trial_app_workflow: MagicMo with pytest.raises(InvokeRateLimitHttpError): method(api, account, trial_app_workflow) - def test_workflow_value_error(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: + def test_workflow_value_error(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -209,7 +242,7 @@ def test_workflow_value_error(self, app: Flask, trial_app_workflow: MagicMock, a with pytest.raises(ValueError): method(api, account, trial_app_workflow) - def test_workflow_generic_exception(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: + def test_workflow_generic_exception(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -226,7 +259,7 @@ def test_workflow_generic_exception(self, app: Flask, trial_app_workflow: MagicM class TestTrialChatApi: - def test_not_chat_app(self, app: Flask, account: Account) -> None: + def test_not_chat_app(self, app: Flask, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -234,7 +267,7 @@ def test_not_chat_app(self, app: Flask, account: Account) -> None: with pytest.raises(NotChatAppError): method(api, account, MagicMock(mode="completion")) - def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_success(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -247,7 +280,7 @@ def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) assert result is not None - def test_chat_conversation_not_exists(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_chat_conversation_not_exists(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -262,7 +295,7 @@ def test_chat_conversation_not_exists(self, app: Flask, trial_app_chat: MagicMoc with pytest.raises(NotFound): method(api, account, trial_app_chat) - def test_chat_conversation_completed(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_chat_conversation_completed(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -277,7 +310,7 @@ def test_chat_conversation_completed(self, app: Flask, trial_app_chat: MagicMock with pytest.raises(ConversationCompletedError): method(api, account, trial_app_chat) - def test_chat_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_chat_app_config_broken(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -292,7 +325,7 @@ def test_chat_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, acc with pytest.raises(AppUnavailableError): method(api, account, trial_app_chat) - def test_chat_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_chat_provider_not_init(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -307,7 +340,7 @@ def test_chat_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, acc with pytest.raises(ProviderNotInitializeError): method(api, account, trial_app_chat) - def test_chat_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_chat_quota_exceeded(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -322,7 +355,7 @@ def test_chat_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, accoun with pytest.raises(ProviderQuotaExceededError): method(api, account, trial_app_chat) - def test_chat_model_not_support(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_chat_model_not_support(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -337,7 +370,7 @@ def test_chat_model_not_support(self, app: Flask, trial_app_chat: MagicMock, acc with pytest.raises(ProviderModelCurrentlyNotSupportError): method(api, account, trial_app_chat) - def test_chat_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_chat_invoke_error(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -352,7 +385,7 @@ def test_chat_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: with pytest.raises(CompletionRequestError): method(api, account, trial_app_chat) - def test_chat_rate_limit_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_chat_rate_limit_error(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -367,7 +400,7 @@ def test_chat_rate_limit_error(self, app: Flask, trial_app_chat: MagicMock, acco with pytest.raises(InvokeRateLimitHttpError): method(api, account, trial_app_chat) - def test_chat_value_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_chat_value_error(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -382,7 +415,7 @@ def test_chat_value_error(self, app: Flask, trial_app_chat: MagicMock, account: with pytest.raises(ValueError): method(api, account, trial_app_chat) - def test_chat_generic_exception(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_chat_generic_exception(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -399,7 +432,7 @@ def test_chat_generic_exception(self, app: Flask, trial_app_chat: MagicMock, acc class TestTrialCompletionApi: - def test_not_completion_app(self, app: Flask, account: Account) -> None: + def test_not_completion_app(self, app: Flask, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -407,7 +440,7 @@ def test_not_completion_app(self, app: Flask, account: Account) -> None: with pytest.raises(NotCompletionAppError): method(api, account, MagicMock(mode=AppMode.CHAT)) - def test_success(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: + def test_success(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -420,7 +453,7 @@ def test_success(self, app: Flask, trial_app_completion: MagicMock, account: Acc assert result is not None - def test_completion_app_config_broken(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: + def test_completion_app_config_broken(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -435,7 +468,7 @@ def test_completion_app_config_broken(self, app: Flask, trial_app_completion: Ma with pytest.raises(AppUnavailableError): method(api, account, trial_app_completion) - def test_completion_provider_not_init(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: + def test_completion_provider_not_init(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -450,7 +483,7 @@ def test_completion_provider_not_init(self, app: Flask, trial_app_completion: Ma with pytest.raises(ProviderNotInitializeError): method(api, account, trial_app_completion) - def test_completion_quota_exceeded(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: + def test_completion_quota_exceeded(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -465,7 +498,7 @@ def test_completion_quota_exceeded(self, app: Flask, trial_app_completion: Magic with pytest.raises(ProviderQuotaExceededError): method(api, account, trial_app_completion) - def test_completion_model_not_support(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: + def test_completion_model_not_support(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -480,7 +513,7 @@ def test_completion_model_not_support(self, app: Flask, trial_app_completion: Ma with pytest.raises(ProviderModelCurrentlyNotSupportError): method(api, account, trial_app_completion) - def test_completion_invoke_error(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: + def test_completion_invoke_error(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -495,7 +528,7 @@ def test_completion_invoke_error(self, app: Flask, trial_app_completion: MagicMo with pytest.raises(CompletionRequestError): method(api, account, trial_app_completion) - def test_completion_rate_limit_error(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: + def test_completion_rate_limit_error(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -510,7 +543,7 @@ def test_completion_rate_limit_error(self, app: Flask, trial_app_completion: Mag with pytest.raises(InternalServerError): method(api, account, trial_app_completion) - def test_completion_value_error(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: + def test_completion_value_error(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -525,7 +558,7 @@ def test_completion_value_error(self, app: Flask, trial_app_completion: MagicMoc with pytest.raises(ValueError): method(api, account, trial_app_completion) - def test_completion_generic_exception(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: + def test_completion_generic_exception(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -542,7 +575,7 @@ def test_completion_generic_exception(self, app: Flask, trial_app_completion: Ma class TestTrialMessageSuggestedQuestionApi: - def test_not_chat_app(self, app: Flask, account: Account) -> None: + def test_not_chat_app(self, app: Flask, account): api = module.TrialMessageSuggestedQuestionApi() method = unwrap(api.get) @@ -550,7 +583,7 @@ def test_not_chat_app(self, app: Flask, account: Account) -> None: with pytest.raises(NotChatAppError): method(api, account, MagicMock(mode="completion"), str(uuid4())) - def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_success(self, app: Flask, trial_app_chat, account): api = module.TrialMessageSuggestedQuestionApi() method = unwrap(api.get) @@ -566,7 +599,7 @@ def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) assert result == {"data": ["q1", "q2"]} - def test_conversation_not_exists(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_conversation_not_exists(self, app: Flask, trial_app_chat, account): api = module.TrialMessageSuggestedQuestionApi() method = unwrap(api.get) @@ -583,14 +616,14 @@ def test_conversation_not_exists(self, app: Flask, trial_app_chat: MagicMock, ac class TestTrialAppParameterApi: - def test_app_unavailable(self) -> None: + def test_app_unavailable(self): api = module.TrialAppParameterApi() method = unwrap(api.get) with pytest.raises(AppUnavailableError): method(api, None) - def test_success_non_workflow(self, valid_parameters: dict[str, object]) -> None: + def test_success_non_workflow(self, valid_parameters): api = module.TrialAppParameterApi() method = unwrap(api.get) @@ -617,11 +650,12 @@ def test_success_non_workflow(self, valid_parameters: dict[str, object]) -> None class TestTrialChatAudioApi: - def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_success(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = _file_data() + file_data: Any = BytesIO(b"fake audio data") + file_data.filename = "test.wav" with ( app.test_request_context( @@ -634,11 +668,12 @@ def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) assert result == {"text": "hello"} - def test_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_app_config_broken(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = _file_data() + file_data: Any = BytesIO(b"fake audio data") + file_data.filename = "test.wav" with ( app.test_request_context( @@ -653,11 +688,12 @@ def test_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: with pytest.raises(module.AppUnavailableError): method(api, account, trial_app_chat) - def test_no_audio_uploaded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_no_audio_uploaded(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = _file_data() + file_data: Any = BytesIO(b"fake audio data") + file_data.filename = "test.wav" with ( app.test_request_context( @@ -672,11 +708,12 @@ def test_no_audio_uploaded(self, app: Flask, trial_app_chat: MagicMock, account: with pytest.raises(module.NoAudioUploadedError): method(api, account, trial_app_chat) - def test_audio_too_large(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_audio_too_large(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = _file_data() + file_data: Any = BytesIO(b"fake audio data") + file_data.filename = "test.wav" with ( app.test_request_context( @@ -691,11 +728,12 @@ def test_audio_too_large(self, app: Flask, trial_app_chat: MagicMock, account: A with pytest.raises(module.AudioTooLargeError): method(api, account, trial_app_chat) - def test_unsupported_audio_type(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_unsupported_audio_type(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = _file_data() + file_data: Any = BytesIO(b"fake audio data") + file_data.filename = "test.wav" with ( app.test_request_context( @@ -710,11 +748,12 @@ def test_unsupported_audio_type(self, app: Flask, trial_app_chat: MagicMock, acc with pytest.raises(module.UnsupportedAudioTypeError): method(api, account, trial_app_chat) - def test_provider_not_support_tts(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_provider_not_support_tts(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = _file_data() + file_data: Any = BytesIO(b"fake audio data") + file_data.filename = "test.wav" with ( app.test_request_context( @@ -729,11 +768,12 @@ def test_provider_not_support_tts(self, app: Flask, trial_app_chat: MagicMock, a with pytest.raises(module.ProviderNotSupportSpeechToTextError): method(api, account, trial_app_chat) - def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_provider_not_init(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = _file_data() + file_data: Any = BytesIO(b"fake audio data") + file_data.filename = "test.wav" with ( app.test_request_context( @@ -744,11 +784,12 @@ def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: with pytest.raises(ProviderNotInitializeError): method(api, account, trial_app_chat) - def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_quota_exceeded(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = _file_data() + file_data: Any = BytesIO(b"fake audio data") + file_data.filename = "test.wav" with ( app.test_request_context( @@ -761,7 +802,7 @@ def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Ac class TestTrialChatTextApi: - def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_success(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -774,7 +815,7 @@ def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) assert result == {"audio": "base64_data"} - def test_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_app_config_broken(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -789,7 +830,7 @@ def test_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: with pytest.raises(module.AppUnavailableError): method(api, account, trial_app_chat) - def test_provider_not_support(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_provider_not_support(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -804,7 +845,7 @@ def test_provider_not_support(self, app: Flask, trial_app_chat: MagicMock, accou with pytest.raises(module.ProviderNotSupportSpeechToTextError): method(api, account, trial_app_chat) - def test_audio_too_large(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_audio_too_large(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -819,7 +860,7 @@ def test_audio_too_large(self, app: Flask, trial_app_chat: MagicMock, account: A with pytest.raises(module.AudioTooLargeError): method(api, account, trial_app_chat) - def test_no_audio_uploaded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_no_audio_uploaded(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -834,7 +875,7 @@ def test_no_audio_uploaded(self, app: Flask, trial_app_chat: MagicMock, account: with pytest.raises(module.NoAudioUploadedError): method(api, account, trial_app_chat) - def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_provider_not_init(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -845,7 +886,7 @@ def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: with pytest.raises(ProviderNotInitializeError): method(api, account, trial_app_chat) - def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_quota_exceeded(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -856,7 +897,7 @@ def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Ac with pytest.raises(ProviderQuotaExceededError): method(api, account, trial_app_chat) - def test_model_not_support(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_model_not_support(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -867,7 +908,7 @@ def test_model_not_support(self, app: Flask, trial_app_chat: MagicMock, account: with pytest.raises(ProviderModelCurrentlyNotSupportError): method(api, account, trial_app_chat) - def test_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_invoke_error(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -880,7 +921,7 @@ def test_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: Acco class TestTrialAppWorkflowTaskStopApi: - def test_not_workflow_app(self, app: Flask, trial_app_chat: MagicMock) -> None: + def test_not_workflow_app(self, app: Flask, trial_app_chat): api = module.TrialAppWorkflowTaskStopApi() method = unwrap(api.post) @@ -888,7 +929,7 @@ def test_not_workflow_app(self, app: Flask, trial_app_chat: MagicMock) -> None: with pytest.raises(NotWorkflowAppError): method(api, trial_app_chat, str(uuid4())) - def test_success(self, app: Flask, trial_app_workflow: MagicMock) -> None: + def test_success(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowTaskStopApi() method = unwrap(api.post) @@ -906,7 +947,7 @@ def test_success(self, app: Flask, trial_app_workflow: MagicMock) -> None: class TestTrialSitApi: - def test_no_site(self, app: Flask) -> None: + def test_no_site(self, app: Flask): api = module.TrialSitApi() method = unwrap(api.get) app_model = MagicMock() @@ -917,7 +958,7 @@ def test_no_site(self, app: Flask) -> None: with pytest.raises(Forbidden): method(api, app_model) - def test_archived_tenant(self, app: Flask) -> None: + def test_archived_tenant(self, app: Flask): api = module.TrialSitApi() method = unwrap(api.get) @@ -932,7 +973,7 @@ def test_archived_tenant(self, app: Flask) -> None: with pytest.raises(Forbidden): method(api, app_model) - def test_success(self, app: Flask) -> None: + def test_success(self, app: Flask): api = module.TrialSitApi() method = unwrap(api.get) @@ -957,11 +998,12 @@ def test_success(self, app: Flask) -> None: class TestTrialChatAudioApiExceptionHandlers: - def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_provider_not_init(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = _file_data() + file_data: Any = BytesIO(b"fake audio data") + file_data.filename = "test.wav" with ( app.test_request_context( @@ -976,11 +1018,12 @@ def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: with pytest.raises(ProviderNotInitializeError): method(api, account, trial_app_chat) - def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_quota_exceeded(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = _file_data() + file_data: Any = BytesIO(b"fake audio data") + file_data.filename = "test.wav" with ( app.test_request_context( @@ -995,11 +1038,12 @@ def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Ac with pytest.raises(ProviderQuotaExceededError): method(api, account, trial_app_chat) - def test_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_invoke_error(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = _file_data() + file_data: Any = BytesIO(b"fake audio data") + file_data.filename = "test.wav" with ( app.test_request_context( @@ -1016,7 +1060,7 @@ def test_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: Acco class TestTrialChatTextApiExceptionHandlers: - def test_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_app_config_broken(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -1031,7 +1075,7 @@ def test_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: with pytest.raises(module.AppUnavailableError): method(api, account, trial_app_chat) - def test_unsupported_audio_type(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: + def test_unsupported_audio_type(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py index b20dd3e30a7ad0..122df3756505bf 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py @@ -3,7 +3,6 @@ import json from datetime import datetime from inspect import unwrap -from types import SimpleNamespace from unittest.mock import Mock import pytest @@ -13,6 +12,7 @@ from controllers.console.snippets import snippet_workflow as snippet_workflow_module from models.account import Account, TenantAccountRole from models.snippet import CustomizedSnippet +from models.workflow import Workflow, WorkflowKind, WorkflowType def _account(account_id: str = "account-1") -> Account: @@ -35,6 +35,38 @@ def _snippet(**overrides) -> CustomizedSnippet: return CustomizedSnippet(**data) +def _workflow(**overrides) -> Workflow: + workflow = Workflow.new( + tenant_id="tenant-1", + app_id="snippet-1", + type=WorkflowType.WORKFLOW.value, + version="2024-01-01 00:00:00", + graph=json.dumps({"nodes": [], "edges": []}), + features=json.dumps({}), + created_by="account-1", + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + kind=WorkflowKind.SNIPPET.value, + marked_name="", + marked_comment="", + ) + workflow.id = "workflow-1" + for key, value in overrides.items(): + setattr(workflow, key, value) + return workflow + + +class _DbStub: + engine = object() + + +class _SessionStub: + def __init__(self, merged_snippet: CustomizedSnippet) -> None: + self.merge = Mock(return_value=merged_snippet) + self.commit = Mock() + + @pytest.fixture(autouse=True) def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch) -> None: def factory(): @@ -93,10 +125,15 @@ def view(**kwargs): def test_draft_workflow_get_raises_when_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: snippet = _snippet() + + class SnippetServiceStub: + def get_draft_workflow(self, *, snippet: CustomizedSnippet) -> None: + return None + monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), + SnippetServiceStub, ) api = snippet_workflow_module.SnippetDraftWorkflowApi() @@ -111,10 +148,15 @@ def test_draft_workflow_post_returns_400_for_invalid_graph(app: Flask, monkeypat user = _account("account-1") snippet = _snippet() sync_draft_workflow = Mock(side_effect=ValueError("invalid graph")) + + class SnippetServiceStub: + def sync_draft_workflow(self, **kwargs): + return sync_draft_workflow(**kwargs) + monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace(sync_draft_workflow=sync_draft_workflow), + SnippetServiceStub, ) api = snippet_workflow_module.SnippetDraftWorkflowApi() @@ -131,27 +173,77 @@ def test_draft_workflow_post_returns_400_for_invalid_graph(app: Flask, monkeypat assert response == {"message": "invalid graph"} +def _response_model_name(entry: object) -> str: + assert isinstance(entry, tuple) + assert len(entry) >= 2 + model = entry[1] + name = getattr(model, "name", None) + assert isinstance(name, str) + return name + + +def test_snippet_workflow_endpoints_keep_response_docs() -> None: + assert snippet_workflow_module.SnippetDefaultBlockConfigsApi.get.__apidoc__["responses"]["200"] == ( + "Default block configs retrieved successfully", + None, + {}, + ) + + cases = [ + ( + snippet_workflow_module.SnippetDraftConfigApi.get, + snippet_workflow_module.SnippetDraftConfigResponse.__name__, + ), + ( + snippet_workflow_module.SnippetDraftRunIterationNodeApi.post, + snippet_workflow_module.EventStreamResponse.__name__, + ), + ( + snippet_workflow_module.SnippetDraftRunLoopNodeApi.post, + snippet_workflow_module.EventStreamResponse.__name__, + ), + ( + snippet_workflow_module.SnippetDraftWorkflowRunApi.post, + snippet_workflow_module.EventStreamResponse.__name__, + ), + ( + snippet_workflow_module.SnippetWorkflowTaskStopApi.post, + snippet_workflow_module.SimpleResultResponse.__name__, + ), + ] + + for view, model_name in cases: + responses = getattr(view, "__apidoc__", {}).get("responses", {}) + assert _response_model_name(responses["200"]) == model_name + + def test_draft_config_returns_parallel_depth_limit(app) -> None: api = snippet_workflow_module.SnippetDraftConfigApi() handler = unwrap(api.get) + snippet = _snippet() with app.test_request_context("/snippets/snippet-1/workflows/draft/config"): - assert handler(api, snippet=SimpleNamespace(id="snippet-1")) == {"parallel_depth_limit": 3} + assert handler(api, snippet=snippet) == {"parallel_depth_limit": 3} def test_published_workflow_get_returns_none_when_not_published(app) -> None: api = snippet_workflow_module.SnippetPublishedWorkflowApi() handler = unwrap(api.get) + snippet = _snippet(is_published=False) with app.test_request_context("/snippets/snippet-1/workflows/publish"): - assert handler(api, snippet=SimpleNamespace(id="snippet-1", is_published=False)) is None + assert handler(api, snippet=snippet) is None def test_published_workflow_post_returns_400_when_publish_fails(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: user = _account("account-1") snippet = _snippet() merged_snippet = _snippet() - session = SimpleNamespace(merge=Mock(return_value=merged_snippet), commit=Mock()) + session = _SessionStub(merged_snippet) + + class SnippetServiceStub: + def publish_workflow(self, **kwargs): + raise ValueError("No valid workflow found.") class SessionContext: def __init__(self, engine): @@ -164,11 +256,11 @@ def __exit__(self, exc_type, exc, tb): return False monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext) - monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(snippet_workflow_module, "db", _DbStub()) monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace(publish_workflow=Mock(side_effect=ValueError("No valid workflow found."))), + SnippetServiceStub, ) api = snippet_workflow_module.SnippetPublishedWorkflowApi() @@ -182,44 +274,97 @@ def __exit__(self, exc_type, exc, tb): session.commit.assert_not_called() +def test_published_workflow_post_returns_publish_result(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + user = _account("account-1") + snippet = _snippet() + merged_snippet = _snippet() + workflow = _workflow(marked_name="Release 1", marked_comment="Initial release") + session = _SessionStub(merged_snippet) + publish_workflow = Mock(return_value=workflow) + + class SnippetServiceStub: + def publish_workflow(self, **kwargs): + return publish_workflow(**kwargs) + + class SessionContext: + def __init__(self, engine): + self.engine = engine + + def __enter__(self): + return session + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext) + monkeypatch.setattr(snippet_workflow_module, "db", _DbStub()) + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + SnippetServiceStub, + ) + + api = snippet_workflow_module.SnippetPublishedWorkflowApi() + handler = unwrap(api.post) + + with app.test_request_context( + "/snippets/snippet-1/workflows/publish", + method="POST", + json={"marked_name": "Release 1", "marked_comment": "Initial release"}, + ): + response = handler(api, user, snippet) + + assert response == {"result": "success", "created_at": int(workflow.created_at.timestamp())} + publish_workflow.assert_called_once_with( + session=session, + snippet=merged_snippet, + account=user, + ) + session.commit.assert_called_once() + + def test_default_block_configs_delegates_to_service(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: get_default_block_configs = Mock(return_value=[{"type": "llm"}]) + + class SnippetServiceStub: + def get_default_block_configs(self): + return get_default_block_configs() + monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace(get_default_block_configs=get_default_block_configs), + SnippetServiceStub, ) api = snippet_workflow_module.SnippetDefaultBlockConfigsApi() handler = unwrap(api.get) + snippet = _snippet() with app.test_request_context("/snippets/snippet-1/workflows/default-workflow-block-configs"): - result = handler(api, snippet=SimpleNamespace(id="snippet-1")) + result = handler(api, snippet=snippet) assert result == [{"type": "llm"}] get_default_block_configs.assert_called_once() def test_list_published_snippet_workflows_includes_input_fields(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - workflow = SimpleNamespace( - id="workflow-1", - graph_dict={"nodes": [], "edges": []}, - features_dict={}, - unique_hash="hash-1", + workflow = _workflow( + graph=json.dumps({"nodes": [], "edges": []}), + features=json.dumps({}), version="2024-01-01 00:00:00", marked_name="", marked_comment="", - created_by_account=None, created_at=datetime(2024, 1, 1), - updated_by_account=None, updated_at=datetime(2024, 1, 1), - tool_published=False, environment_variables=[], conversation_variables=[], rag_pipeline_variables=[], ) input_fields = [{"variable": "query", "type": "text"}] snippet = _snippet(input_fields=json.dumps(input_fields)) + monkeypatch.setattr(Workflow, "created_by_account", property(lambda _workflow: None)) + monkeypatch.setattr(Workflow, "updated_by_account", property(lambda _workflow: None)) + monkeypatch.setattr(Workflow, "tool_published", property(lambda _workflow: False)) class SessionContext: def __init__(self, engine): @@ -232,11 +377,16 @@ def __exit__(self, exc_type, exc, tb): return False monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext) - monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(snippet_workflow_module, "db", _DbStub()) + + class SnippetServiceStub: + def get_all_published_workflows(self, **kwargs): + return [workflow], False + monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace(get_all_published_workflows=Mock(return_value=([workflow], False))), + SnippetServiceStub, ) api = snippet_workflow_module.SnippetPublishedAllWorkflowApi() @@ -249,18 +399,21 @@ def __exit__(self, exc_type, exc, tb): def test_restore_published_snippet_workflow_to_draft_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - workflow = SimpleNamespace( - unique_hash="restored-hash", + workflow = _workflow( updated_at=None, created_at=datetime(2024, 1, 1), ) user = _account("account-1") snippet = _snippet() + class SnippetServiceStub: + def restore_published_workflow_to_draft(self, **kwargs): + return workflow + monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace(restore_published_workflow_to_draft=lambda **_kwargs: workflow), + SnippetServiceStub, ) api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi() @@ -273,21 +426,21 @@ def test_restore_published_snippet_workflow_to_draft_success(app: Flask, monkeyp response = handler(api, user, snippet, workflow_id="published-workflow") assert response["result"] == "success" - assert response["hash"] == "restored-hash" + assert response["hash"] == workflow.unique_hash def test_restore_published_snippet_workflow_to_draft_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: user = _account("account-1") snippet = _snippet() + class SnippetServiceStub: + def restore_published_workflow_to_draft(self, **kwargs): + raise snippet_workflow_module.WorkflowNotFoundError("Workflow not found") + monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace( - restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw( - snippet_workflow_module.WorkflowNotFoundError("Workflow not found") - ) - ), + SnippetServiceStub, ) api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi() @@ -307,14 +460,14 @@ def test_restore_published_snippet_workflow_to_draft_returns_400_for_draft_sourc user = _account("account-1") snippet = _snippet() + class SnippetServiceStub: + def restore_published_workflow_to_draft(self, **kwargs): + raise snippet_workflow_module.IsDraftWorkflowError("source workflow must be published") + monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace( - restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw( - snippet_workflow_module.IsDraftWorkflowError("source workflow must be published") - ) - ), + SnippetServiceStub, ) api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi() @@ -337,14 +490,14 @@ def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_gra user = _account("account-1") snippet = _snippet() + class SnippetServiceStub: + def restore_published_workflow_to_draft(self, **kwargs): + raise ValueError("invalid snippet workflow graph") + monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace( - restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw( - ValueError("invalid snippet workflow graph") - ) - ), + SnippetServiceStub, ) api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi() @@ -363,10 +516,15 @@ def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_gra def test_workflow_run_detail_raises_not_found_when_run_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: snippet = _snippet() + + class SnippetServiceStub: + def get_snippet_workflow_run(self, **kwargs) -> None: + return None + monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace(get_snippet_workflow_run=Mock(return_value=None)), + SnippetServiceStub, ) api = snippet_workflow_module.SnippetWorkflowRunDetailApi() @@ -381,14 +539,19 @@ def test_draft_node_last_run_raises_not_found_when_execution_missing( app: Flask, monkeypatch: pytest.MonkeyPatch ) -> None: snippet = _snippet() - draft_workflow = SimpleNamespace(id="workflow-1") + draft_workflow = _workflow(id="workflow-1", version=Workflow.VERSION_DRAFT) + + class SnippetServiceStub: + def get_draft_workflow(self, *, snippet: CustomizedSnippet) -> Workflow: + return draft_workflow + + def get_snippet_node_last_run(self, **kwargs) -> None: + return None + monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace( - get_draft_workflow=Mock(return_value=draft_workflow), - get_snippet_node_last_run=Mock(return_value=None), - ), + SnippetServiceStub, ) api = snippet_workflow_module.SnippetDraftNodeLastRunApi() @@ -402,6 +565,12 @@ def test_draft_node_last_run_raises_not_found_when_execution_missing( def test_workflow_task_stop_uses_queue_flag_and_graph_command(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: set_stop_flag = Mock() send_stop_command = Mock() + + class GraphEngineManagerStub: + def __init__(self, redis_client): + self.redis_client = redis_client + self.send_stop_command = send_stop_command + monkeypatch.setattr( snippet_workflow_module.AppQueueManager, "set_stop_flag_no_user_check", @@ -410,14 +579,15 @@ def test_workflow_task_stop_uses_queue_flag_and_graph_command(app: Flask, monkey monkeypatch.setattr( snippet_workflow_module, "GraphEngineManager", - Mock(return_value=SimpleNamespace(send_stop_command=send_stop_command)), + GraphEngineManagerStub, ) api = snippet_workflow_module.SnippetWorkflowTaskStopApi() handler = unwrap(api.post) + snippet = _snippet() with app.test_request_context("/snippets/snippet-1/workflow-runs/tasks/task-1/stop", method="POST"): - result = handler(api, snippet=SimpleNamespace(id="snippet-1"), task_id="task-1") + result = handler(api, snippet=snippet, task_id="task-1") assert result == {"result": "success"} set_stop_flag.assert_called_once_with("task-1") diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py index 03a6fdb0d60486..054965dc2f58f8 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py @@ -1,15 +1,28 @@ +import json +import uuid +from contextlib import nullcontext from inspect import unwrap from types import SimpleNamespace +from typing import Any, cast from unittest.mock import Mock import pytest from flask import Flask from controllers.console.snippets import snippet_workflow_draft_variable as module -from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID +from factories.variable_factory import build_environment_variable_from_mapping, build_segment +from graphon.variables.types import SegmentType +from models import workflow as workflow_model_module from models.account import Account, AccountStatus +from models.snippet import CustomizedSnippet +from models.workflow import Workflow, WorkflowDraftVariable, WorkflowType from services.workflow_draft_variable_service import WorkflowDraftVariableList +_TEST_SNIPPET_ID = str(uuid.uuid4()) +_TEST_TENANT_ID = str(uuid.uuid4()) +_TEST_USER_ID = str(uuid.uuid4()) +_TEST_NODE_EXECUTION_ID = str(uuid.uuid4()) + def _make_account() -> Account: account = Account( @@ -17,16 +30,97 @@ def _make_account() -> Account: email="tester@example.com", status=AccountStatus.ACTIVE, ) - account.id = "user-1" # type: ignore[assignment] + account.id = _TEST_USER_ID return account +def _make_snippet() -> CustomizedSnippet: + snippet = CustomizedSnippet() + snippet.id = _TEST_SNIPPET_ID + snippet.tenant_id = _TEST_TENANT_ID + snippet.name = "Reusable snippet" + snippet.description = "Snippet under test" + snippet.type = "node" + return snippet + + +def _make_draft_workflow(*, environment_variables: list[Any] | None = None) -> Workflow: + workflow = Workflow() + workflow.id = str(uuid.uuid4()) + workflow.tenant_id = _TEST_TENANT_ID + workflow.app_id = _TEST_SNIPPET_ID + workflow.type = WorkflowType.SNIPPET + workflow.version = Workflow.VERSION_DRAFT + workflow.graph = "{}" + workflow.features = "{}" + workflow.created_by = _TEST_USER_ID + workflow._environment_variables = json.dumps( + {variable.id: variable.model_dump(mode="json") for variable in environment_variables or []} + ) + return workflow + + +def _make_node_variable( + *, value: Any = "value", node_id: str = "llm-1", name: str = "node_var" +) -> WorkflowDraftVariable: + return WorkflowDraftVariable.new_node_variable( + app_id=_TEST_SNIPPET_ID, + user_id=_TEST_USER_ID, + node_id=node_id, + name=name, + value=build_segment(value), + node_execution_id=_TEST_NODE_EXECUTION_ID, + ) + + +def _make_system_variable() -> WorkflowDraftVariable: + return WorkflowDraftVariable.new_sys_variable( + app_id=_TEST_SNIPPET_ID, + user_id=_TEST_USER_ID, + name="query", + value=build_segment("hello"), + node_execution_id=_TEST_NODE_EXECUTION_ID, + ) + + +def _make_conversation_variable() -> WorkflowDraftVariable: + return WorkflowDraftVariable.new_conversation_variable( + app_id=_TEST_SNIPPET_ID, + user_id=_TEST_USER_ID, + name="topic", + value=build_segment("support"), + ) + + +def _expected_variable_payload(variable: WorkflowDraftVariable, *, value: Any) -> dict[str, Any]: + expected_without_value = _expected_variable_without_value_payload(variable) + return { + **expected_without_value, + "value": value, + "full_content": None, + } + + +def _expected_variable_without_value_payload(variable: WorkflowDraftVariable) -> dict[str, Any]: + return { + "id": variable.id, + "type": "node", + "name": variable.name, + "description": "", + "selector": [variable.node_id, variable.name], + "value_type": str(variable.value_type.exposed_type()), + "edited": False, + "visible": True, + "is_truncated": False, + } + + @pytest.fixture(autouse=True) def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch): def factory(): service_factory = module.SnippetService if isinstance(service_factory, type): - return service_factory.__new__(service_factory) + return cast(Any, service_factory).__new__(service_factory) return service_factory() monkeypatch.setattr(module, "_snippet_service", factory) @@ -40,23 +134,17 @@ def app(): def test_ensure_snippet_draft_variable_row_allowed_rejects_system_variable(): - variable = SimpleNamespace(node_id=SYSTEM_VARIABLE_NODE_ID) - with pytest.raises(module.NotFoundError, match="variable not found"): - module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") + module._ensure_snippet_draft_variable_row_allowed(variable=_make_system_variable(), variable_id="var-1") def test_ensure_snippet_draft_variable_row_allowed_rejects_conversation_variable(): - variable = SimpleNamespace(node_id=CONVERSATION_VARIABLE_NODE_ID) - with pytest.raises(module.NotFoundError, match="variable not found"): - module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") + module._ensure_snippet_draft_variable_row_allowed(variable=_make_conversation_variable(), variable_id="var-1") def test_ensure_snippet_draft_variable_row_allowed_accepts_canvas_node_variable(): - variable = SimpleNamespace(node_id="llm-1") - - module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") + module._ensure_snippet_draft_variable_row_allowed(variable=_make_node_variable(), variable_id="var-1") def test_conversation_variables_returns_empty_list(app: Flask): @@ -64,9 +152,9 @@ def test_conversation_variables_returns_empty_list(app: Flask): handler = unwrap(api.get) with app.test_request_context("/"): - result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1")) + result = handler(api, _make_account(), snippet=_make_snippet()) - assert result == WorkflowDraftVariableList(variables=[]) + assert result == {"items": []} def test_system_variables_returns_empty_list(app: Flask): @@ -74,9 +162,9 @@ def test_system_variables_returns_empty_list(app: Flask): handler = unwrap(api.get) with app.test_request_context("/"): - result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1")) + result = handler(api, _make_account(), snippet=_make_snippet()) - assert result == WorkflowDraftVariableList(variables=[]) + assert result == {"items": []} def test_delete_variable_collection_deletes_current_user_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): @@ -89,10 +177,10 @@ def test_delete_variable_collection_deletes_current_user_variables(app: Flask, m handler = unwrap(api.delete) with app.test_request_context("/", method="DELETE"): - response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1")) + response = handler(api, _make_account(), snippet=_make_snippet()) - assert response.status_code == 204 - draft_var_service.delete_user_workflow_variables.assert_called_once_with("snippet-1", user_id="user-1") + assert response == ("", 204) + draft_var_service.delete_user_workflow_variables.assert_called_once_with(_TEST_SNIPPET_ID, user_id=_TEST_USER_ID) db_session.commit.assert_called_once() @@ -108,11 +196,58 @@ def test_variable_collection_get_raises_when_draft_workflow_missing(app: Flask, with app.test_request_context("/?page=1&limit=20"): with pytest.raises(module.DraftWorkflowNotExist): - handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1")) + handler(api, _make_account(), snippet=_make_snippet()) + + +def test_variable_collection_get_returns_without_value_contract(app, monkeypatch): + variable = _make_node_variable(value="hidden-value") + draft_workflow = _make_draft_workflow() + captured_args: dict[str, Any] = {} + + class DraftVariableService: + def __init__(self, *, session: object) -> None: + captured_args["session"] = session + + def list_variables_without_values(self, **kwargs: Any) -> WorkflowDraftVariableList: + captured_args.update(kwargs) + return WorkflowDraftVariableList(variables=[variable], total=9) + + session = object() + monkeypatch.setattr( + module, + "SnippetService", + Mock(return_value=SimpleNamespace(get_draft_workflow=Mock(return_value=draft_workflow))), + ) + monkeypatch.setattr(module, "WorkflowDraftVariableService", DraftVariableService) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + module, + "Session", + lambda *args, **kwargs: nullcontext(session), + ) + + api = module.SnippetWorkflowVariableCollectionApi() + handler = unwrap(api.get) + + with app.test_request_context("/?page=2&limit=3", method="GET"): + result = handler(api, _make_account(), snippet=_make_snippet()) + + expected = {"items": [_expected_variable_without_value_payload(variable)], "total": 9} + assert captured_args == { + "session": session, + "app_id": _TEST_SNIPPET_ID, + "page": 2, + "limit": 3, + "user_id": _TEST_USER_ID, + "exclude_node_ids": module._SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS, + } + assert result == expected + module.WorkflowDraftVariableListWithoutValueResponse.model_validate(result) def test_node_variable_collection_get_lists_node_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): - variables = WorkflowDraftVariableList(variables=[SimpleNamespace(id="var-1")]) + variable = _make_node_variable(value=None) + variables = WorkflowDraftVariableList(variables=[variable]) list_node_variables = Mock(return_value=variables) class SessionContext: @@ -138,10 +273,12 @@ def __exit__(self, exc_type, exc, tb): handler = unwrap(api.get) with app.test_request_context("/"): - result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1") + result = handler(api, _make_account(), snippet=_make_snippet(), node_id="llm-1") - assert result is variables - list_node_variables.assert_called_once_with("snippet-1", "llm-1", user_id="user-1") + expected = {"items": [_expected_variable_payload(variable, value=None)]} + assert result == expected + module.WorkflowDraftVariableListResponse.model_validate(result) + list_node_variables.assert_called_once_with(_TEST_SNIPPET_ID, "llm-1", user_id=_TEST_USER_ID) def test_node_variable_collection_delete_deletes_node_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): @@ -156,15 +293,15 @@ def test_node_variable_collection_delete_deletes_node_variables(app: Flask, monk handler = unwrap(api.delete) with app.test_request_context("/", method="DELETE"): - response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1") + response = handler(api, _make_account(), snippet=_make_snippet(), node_id="llm-1") - assert response.status_code == 204 - delete_node_variables.assert_called_once_with("snippet-1", "llm-1", user_id="user-1") + assert response == ("", 204) + delete_node_variables.assert_called_once_with(_TEST_SNIPPET_ID, "llm-1", user_id=_TEST_USER_ID) db_session.commit.assert_called_once() def test_variable_patch_returns_variable_when_no_changes(app: Flask, monkeypatch: pytest.MonkeyPatch): - variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") + variable = _make_node_variable(value=42) draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), update_variable=Mock()) db_session = Mock() db_session.return_value = SimpleNamespace() @@ -178,17 +315,76 @@ def test_variable_patch_returns_variable_when_no_changes(app: Flask, monkeypatch result = handler( api, _make_account(), - snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), - variable_id="var-1", + snippet=_make_snippet(), + variable_id=variable.id, ) - assert result is variable + expected = _expected_variable_payload(variable, value=42) + assert result == expected + module.WorkflowDraftVariableResponse.model_validate(result) draft_var_service.update_variable.assert_not_called() db_session.commit.assert_not_called() +def test_variable_get_returns_raw_response_contract(app: Flask, monkeypatch: pytest.MonkeyPatch): + variable = _make_node_variable(value={"count": 2, "enabled": True}) + draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable)) + db_session = Mock() + db_session.return_value = SimpleNamespace() + monkeypatch.setattr(module.db, "session", db_session) + monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) + + api = module.SnippetVariableApi() + handler = unwrap(api.get) + + with app.test_request_context("/", method="GET"): + result = handler(api, _make_account(), snippet=_make_snippet(), variable_id=variable.id) + + expected = _expected_variable_payload(variable, value={"count": 2, "enabled": True}) + assert result == expected + module.WorkflowDraftVariableResponse.model_validate(result) + draft_var_service.get_variable.assert_called_once_with(variable_id=variable.id) + + +def test_variable_patch_updates_from_pydantic_payload_and_returns_raw_contract( + app: Flask, monkeypatch: pytest.MonkeyPatch +): + variable = _make_node_variable(value=1, name="old_name") + request_model = module.WorkflowDraftVariableUpdatePayload.model_validate({"name": "renamed", "value": 13}) + + class DraftVariableService: + def __init__(self, session: object) -> None: + pass + + def get_variable(self, *, variable_id: str) -> WorkflowDraftVariable: + assert variable_id == variable.id + return variable + + def update_variable(self, target: WorkflowDraftVariable, *, name: str | None, value: Any) -> None: + assert target is variable + assert name == "renamed" + target.set_name(name) + target.set_value(value) + + session = Mock(return_value=object()) + session.commit = Mock() + monkeypatch.setattr(module, "WorkflowDraftVariableService", DraftVariableService) + monkeypatch.setattr(module.db, "session", session) + + api = module.SnippetVariableApi() + handler = unwrap(api.patch) + + with app.test_request_context("/", method="PATCH", json=request_model.model_dump(mode="json")): + result = handler(api, _make_account(), snippet=_make_snippet(), variable_id=variable.id) + + expected = _expected_variable_payload(variable, value=13) + assert result == expected + module.WorkflowDraftVariableResponse.model_validate(result) + session.commit.assert_called_once() + + def test_variable_delete_deletes_variable(app: Flask, monkeypatch: pytest.MonkeyPatch): - variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") + variable = _make_node_variable() delete_variable = Mock() draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), delete_variable=delete_variable) db_session = Mock() @@ -200,16 +396,16 @@ def test_variable_delete_deletes_variable(app: Flask, monkeypatch: pytest.Monkey handler = unwrap(api.delete) with app.test_request_context("/", method="DELETE"): - response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1") + response = handler(api, _make_account(), snippet=_make_snippet(), variable_id=variable.id) - assert response.status_code == 204 + assert response == ("", 204) delete_variable.assert_called_once_with(variable) db_session.commit.assert_called_once() def test_variable_reset_returns_no_content_when_reset_result_is_none(app: Flask, monkeypatch: pytest.MonkeyPatch): - variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") - draft_workflow = SimpleNamespace(id="workflow-1") + variable = _make_node_variable() + draft_workflow = _make_draft_workflow() draft_var_service = SimpleNamespace( get_variable=Mock(return_value=variable), reset_variable=Mock(return_value=None), @@ -228,42 +424,75 @@ def test_variable_reset_returns_no_content_when_reset_result_is_none(app: Flask, handler = unwrap(api.put) with app.test_request_context("/", method="PUT"): - response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1") + response = handler(api, _make_account(), snippet=_make_snippet(), variable_id=variable.id) assert response.status_code == 204 draft_var_service.reset_variable.assert_called_once_with(draft_workflow, variable) db_session.commit.assert_called_once() -def test_environment_variables_returns_workflow_environment_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): - env_var = SimpleNamespace( - id="env-1", - name="API_KEY", - description="secret", - selector=["env", "API_KEY"], - value_type=SimpleNamespace(exposed_type=Mock(return_value=SimpleNamespace(value="secret"))), - value="sk-test", +def test_variable_reset_returns_raw_response_contract_when_variable_is_reset( + app: Flask, monkeypatch: pytest.MonkeyPatch +): + variable = _make_node_variable(value="edited") + reset_variable = _make_node_variable(value="default") + reset_variable.id = variable.id + draft_workflow = _make_draft_workflow() + draft_var_service = SimpleNamespace( + get_variable=Mock(return_value=variable), + reset_variable=Mock(return_value=reset_variable), ) + db_session = Mock() + db_session.return_value = SimpleNamespace() + monkeypatch.setattr(module.db, "session", db_session) + monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) monkeypatch.setattr( module, "SnippetService", - Mock( - return_value=SimpleNamespace( - get_draft_workflow=Mock(return_value=SimpleNamespace(environment_variables=[env_var])) - ) - ), + Mock(return_value=SimpleNamespace(get_draft_workflow=Mock(return_value=draft_workflow))), + ) + + api = module.SnippetVariableResetApi() + handler = unwrap(api.put) + + with app.test_request_context("/", method="PUT"): + result = handler(api, _make_account(), snippet=_make_snippet(), variable_id=variable.id) + + expected = _expected_variable_payload(reset_variable, value="default") + assert result == expected + module.WorkflowDraftVariableResponse.model_validate(result) + draft_var_service.reset_variable.assert_called_once_with(draft_workflow, variable) + db_session.commit.assert_called_once() + + +def test_environment_variables_returns_workflow_environment_variables(app, monkeypatch): + env_var = build_environment_variable_from_mapping( + { + "id": str(uuid.uuid4()), + "name": "API_KEY", + "description": "secret", + "value_type": SegmentType.SECRET, + "value": "sk-test", + } + ) + draft_workflow = _make_draft_workflow(environment_variables=[env_var]) + monkeypatch.setattr(workflow_model_module.encrypter, "decrypt_token", lambda tenant_id, token: token) + monkeypatch.setattr( + module, + "SnippetService", + Mock(return_value=SimpleNamespace(get_draft_workflow=Mock(return_value=draft_workflow))), ) api = module.SnippetEnvironmentVariableCollectionApi() handler = unwrap(api.get) with app.test_request_context("/"): - result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1")) + result = handler(api, _make_account(), snippet=_make_snippet()) - assert result == { + expected = { "items": [ { - "id": "env-1", + "id": env_var.id, "type": "env", "name": "API_KEY", "description": "secret", @@ -276,3 +505,5 @@ def test_environment_variables_returns_workflow_environment_variables(app: Flask } ] } + assert result == expected + module.WorkflowDraftEnvironmentVariableListResponse.model_validate(result) diff --git a/api/tests/unit_tests/controllers/console/test_spec.py b/api/tests/unit_tests/controllers/console/test_spec.py index 84c2004ec70ed7..8b79bcd82fc1e2 100644 --- a/api/tests/unit_tests/controllers/console/test_spec.py +++ b/api/tests/unit_tests/controllers/console/test_spec.py @@ -9,7 +9,17 @@ def test_get_success(self): api = spec_module.SpecSchemaDefinitionsApi() method = unwrap(api.get) - schema_definitions = [{"type": "string"}] + schema_definitions = [ + { + "name": "conversation-variable", + "label": "Conversation variable", + "schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + } + ] with patch.object( spec_module, @@ -21,6 +31,12 @@ def test_get_success(self): assert status == 200 assert resp == schema_definitions + assert spec_module.SchemaDefinitionsResponse.model_validate(resp).model_dump(mode="json") == schema_definitions + + def test_get_documents_tight_response_model(self): + response = spec_module.SpecSchemaDefinitionsApi.get.__apidoc__["responses"]["200"] + + assert response[1].name == spec_module.SchemaDefinitionsResponse.__name__ def test_get_exception_returns_empty_list(self): api = spec_module.SpecSchemaDefinitionsApi() diff --git a/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py b/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py index abd9b4facb92be..8db3a2953766f3 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py @@ -1,4 +1,5 @@ import inspect +from datetime import UTC, datetime from unittest.mock import patch import pytest @@ -16,9 +17,32 @@ EndpointListApi, EndpointListForSinglePluginApi, ) +from core.plugin.entities.endpoint import EndpointEntityWithInstance from core.plugin.impl.exc import PluginPermissionDeniedError +def _endpoint_entity() -> EndpointEntityWithInstance: + now = datetime(2026, 1, 1, tzinfo=UTC) + return EndpointEntityWithInstance( + id="e1", + created_at=now, + updated_at=now, + tenant_id="t1", + plugin_id="p1", + settings={ + "secret": "value", + "enabled": True, + "ids": ["a", "b"], + "nested": {"limit": 3}, + }, + expired_at=now, + name="endpoint", + enabled=True, + url="https://example.test/hook-1", + hook_id="hook-1", + ) + + class TestEndpointCollectionApi: def test_create_success(self, app: Flask): api = EndpointCollectionApi() @@ -102,12 +126,34 @@ def test_list_success(self, app: Flask): with ( app.test_request_context("/?page=1&page_size=10"), - patch("controllers.console.workspace.endpoint.EndpointService.list_endpoints", return_value=[{"id": "e1"}]), + patch( + "controllers.console.workspace.endpoint.EndpointService.list_endpoints", + return_value=[_endpoint_entity()], + ), ): result = method(api, "t1", "u1") - assert "endpoints" in result - assert len(result["endpoints"]) == 1 + assert result["endpoints"] == [ + { + "id": "e1", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "settings": { + "secret": "value", + "enabled": True, + "ids": ["a", "b"], + "nested": {"limit": 3}, + }, + "tenant_id": "t1", + "plugin_id": "p1", + "expired_at": "2026-01-01T00:00:00Z", + "declaration": {"settings": [], "endpoints": []}, + "name": "endpoint", + "enabled": True, + "url": "https://example.test/hook-1", + "hook_id": "hook-1", + } + ] def test_list_invalid_query(self, app: Flask): api = EndpointListApi() @@ -129,12 +175,13 @@ def test_list_for_plugin_success(self, app: Flask): app.test_request_context("/?page=1&page_size=10&plugin_id=p1"), patch( "controllers.console.workspace.endpoint.EndpointService.list_endpoints_for_single_plugin", - return_value=[{"id": "e1"}], + return_value=[_endpoint_entity()], ), ): result = method(api, "t1", "u1") - assert "endpoints" in result + assert result["endpoints"][0]["id"] == "e1" + assert result["endpoints"][0]["settings"]["nested"] == {"limit": 3} def test_list_for_plugin_missing_param(self, app: Flask): api = EndpointListForSinglePluginApi() diff --git a/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py index 21055390362565..088ceea83489b0 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py @@ -1,10 +1,14 @@ -from unittest.mock import MagicMock, patch +from inspect import unwrap +from types import SimpleNamespace +from typing import cast +from unittest.mock import patch import pytest from flask import Flask from pydantic_core import ValidationError from werkzeug.exceptions import Forbidden +from configs import dify_config from controllers.console.workspace.model_providers import ( ModelProviderCredentialApi, ModelProviderCredentialSwitchApi, @@ -14,30 +18,126 @@ ModelProviderValidateApi, PreferredProviderTypeUpdateApi, ) +from graphon.model_runtime.entities.common_entities import I18nObject +from graphon.model_runtime.entities.model_entities import ModelType +from graphon.model_runtime.entities.provider_entities import ConfigurateMethod from graphon.model_runtime.errors.validate import CredentialsValidateFailedError +from models import Account +from models.provider import ProviderType +from services.entities.model_provider_entities import ( + CustomConfigurationResponse, + CustomConfigurationStatus, + ProviderResponse, + SystemConfigurationResponse, +) VALID_UUID = "123e4567-e89b-12d3-a456-426614174000" INVALID_UUID = "123" -from inspect import unwrap +def make_account() -> Account: + return cast(Account, SimpleNamespace(id="account-1", email="owner@example.com")) + + +def make_provider_response() -> ProviderResponse: + return ProviderResponse( + tenant_id="tenant1", + provider="openai", + label=I18nObject(en_US="OpenAI", zh_Hans="OpenAI"), + description=I18nObject(en_US="OpenAI models", zh_Hans="OpenAI models zh"), + icon_small=I18nObject(en_US="icon.svg", zh_Hans="icon.svg"), + icon_small_dark=I18nObject(en_US="icon-dark.svg", zh_Hans="icon-dark.svg"), + background="#ffffff", + supported_model_types=[ModelType.LLM, ModelType.TEXT_EMBEDDING], + configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL, ConfigurateMethod.CUSTOMIZABLE_MODEL], + preferred_provider_type=ProviderType.CUSTOM, + custom_configuration=CustomConfigurationResponse( + status=CustomConfigurationStatus.ACTIVE, + current_credential_id=VALID_UUID, + current_credential_name="production", + available_credentials=[], + custom_models=[], + can_added_models=[], + ), + system_configuration=SystemConfigurationResponse( + enabled=True, + current_quota_type=None, + quota_configurations=[], + ), + ) + + +def expected_provider_payload() -> dict[str, object]: + icon_url_prefix = f"{dify_config.CONSOLE_API_URL}/console/api/workspaces/tenant1/model-providers/openai" + return { + "tenant_id": "tenant1", + "provider": "openai", + "label": {"zh_Hans": "OpenAI", "en_US": "OpenAI"}, + "description": {"zh_Hans": "OpenAI models zh", "en_US": "OpenAI models"}, + "icon_small": { + "zh_Hans": f"{icon_url_prefix}/icon_small/zh_Hans", + "en_US": f"{icon_url_prefix}/icon_small/en_US", + }, + "icon_small_dark": { + "zh_Hans": f"{icon_url_prefix}/icon_small_dark/zh_Hans", + "en_US": f"{icon_url_prefix}/icon_small_dark/en_US", + }, + "background": "#ffffff", + "help": None, + "supported_model_types": ["llm", "text-embedding"], + "configurate_methods": ["predefined-model", "customizable-model"], + "provider_credential_schema": None, + "model_credential_schema": None, + "preferred_provider_type": "custom", + "custom_configuration": { + "status": "active", + "current_credential_id": VALID_UUID, + "current_credential_name": "production", + "available_credentials": [], + "custom_models": [], + "can_added_models": [], + }, + "system_configuration": { + "enabled": True, + "current_quota_type": None, + "quota_configurations": [], + }, + } class TestModelProviderListApi: def test_get_success(self, app: Flask): api = ModelProviderListApi() method = unwrap(api.get) + provider = make_provider_response() with ( app.test_request_context("/?model_type=llm"), patch( "controllers.console.workspace.model_providers.ModelProviderService.get_provider_list", - return_value=[{"name": "openai"}], - ), + return_value=[provider], + ) as get_provider_list, + ): + result = method(api, "tenant1") + + get_provider_list.assert_called_once_with(tenant_id="tenant1", model_type=ModelType.LLM) + assert result == {"data": [expected_provider_payload()]} + + def test_get_without_model_type_passes_none(self, app: Flask): + api = ModelProviderListApi() + method = unwrap(api.get) + + with ( + app.test_request_context("/"), + patch( + "controllers.console.workspace.model_providers.ModelProviderService.get_provider_list", + return_value=[], + ) as get_provider_list, ): result = method(api, "tenant1") - assert "data" in result + get_provider_list.assert_called_once_with(tenant_id="tenant1", model_type=None) + assert result == {"data": []} class TestModelProviderCredentialApi: @@ -49,12 +149,41 @@ def test_get_success(self, app: Flask): app.test_request_context(f"/?credential_id={VALID_UUID}"), patch( "controllers.console.workspace.model_providers.ModelProviderService.get_provider_credential", - return_value={"key": "value"}, - ), + return_value={ + "api_key": "sk-test", + "endpoint": "https://api.example.com", + "nested": {"region": "us-east-1"}, + }, + ) as get_provider_credential, + ): + result = method(api, "tenant1", provider="openai") + + get_provider_credential.assert_called_once_with( + tenant_id="tenant1", provider="openai", credential_id=VALID_UUID + ) + assert result == { + "credentials": { + "api_key": "sk-test", + "endpoint": "https://api.example.com", + "nested": {"region": "us-east-1"}, + } + } + + def test_get_current_credential_without_id(self, app: Flask): + api = ModelProviderCredentialApi() + method = unwrap(api.get) + + with ( + app.test_request_context("/"), + patch( + "controllers.console.workspace.model_providers.ModelProviderService.get_provider_credential", + return_value=None, + ) as get_provider_credential, ): result = method(api, "tenant1", provider="openai") - assert "credentials" in result + get_provider_credential.assert_called_once_with(tenant_id="tenant1", provider="openai", credential_id=None) + assert result == {"credentials": None} def test_get_invalid_uuid(self, app: Flask): api = ModelProviderCredentialApi() @@ -75,11 +204,17 @@ def test_post_create_success(self, app: Flask): patch( "controllers.console.workspace.model_providers.ModelProviderService.create_provider_credential", return_value=None, - ), + ) as create_provider_credential, ): result, status = method(api, "tenant1", provider="openai") - assert result["result"] == "success" + create_provider_credential.assert_called_once_with( + tenant_id="tenant1", + provider="openai", + credentials={"a": "b"}, + credential_name="test", + ) + assert result == {"result": "success"} assert status == 201 def test_post_create_validation_error(self, app: Flask): @@ -109,11 +244,18 @@ def test_put_update_success(self, app: Flask): patch( "controllers.console.workspace.model_providers.ModelProviderService.update_provider_credential", return_value=None, - ), + ) as update_provider_credential, ): result = method(api, "tenant1", provider="openai") - assert result["result"] == "success" + update_provider_credential.assert_called_once_with( + tenant_id="tenant1", + provider="openai", + credentials={"a": "b"}, + credential_id=VALID_UUID, + credential_name=None, + ) + assert result == {"result": "success"} def test_put_invalid_uuid(self, app: Flask): api = ModelProviderCredentialApi() @@ -136,10 +278,13 @@ def test_delete_success(self, app: Flask): patch( "controllers.console.workspace.model_providers.ModelProviderService.remove_provider_credential", return_value=None, - ), + ) as remove_provider_credential, ): result, status = method(api, "tenant1", provider="openai") + remove_provider_credential.assert_called_once_with( + tenant_id="tenant1", provider="openai", credential_id=VALID_UUID + ) assert status == 204 assert result == "" @@ -156,11 +301,16 @@ def test_switch_success(self, app: Flask): patch( "controllers.console.workspace.model_providers.ModelProviderService.switch_active_provider_credential", return_value=None, - ), + ) as switch_active_provider_credential, ): result = method(api, "tenant1", provider="openai") - assert result["result"] == "success" + switch_active_provider_credential.assert_called_once_with( + tenant_id="tenant1", + provider="openai", + credential_id=VALID_UUID, + ) + assert result == {"result": "success"} def test_switch_invalid_uuid(self, app: Flask): api = ModelProviderCredentialSwitchApi() @@ -185,11 +335,14 @@ def test_validate_success(self, app: Flask): patch( "controllers.console.workspace.model_providers.ModelProviderService.validate_provider_credentials", return_value=None, - ), + ) as validate_provider_credentials, ): result = method(api, "tenant1", provider="openai") - assert result["result"] == "success" + validate_provider_credentials.assert_called_once_with( + tenant_id="tenant1", provider="openai", credentials={"a": "b"} + ) + assert result == {"result": "success", "error": None} def test_validate_failure(self, app: Flask): api = ModelProviderValidateApi() @@ -206,7 +359,7 @@ def test_validate_failure(self, app: Flask): ): result = method(api, "tenant1", provider="openai") - assert result["result"] == "error" + assert result == {"result": "error", "error": "bad"} class TestModelProviderIconApi: @@ -218,11 +371,14 @@ def test_icon_success(self, app: Flask): patch( "controllers.console.workspace.model_providers.ModelProviderService.get_model_provider_icon", return_value=(b"123", "image/png"), - ), + ) as get_model_provider_icon, ): response = api.get("t1", "openai", "logo", "en") + get_model_provider_icon.assert_called_once_with(tenant_id="t1", provider="openai", icon_type="logo", lang="en") assert response.mimetype == "image/png" + response.direct_passthrough = False + assert response.get_data() == b"123" def test_icon_not_found(self, app: Flask): api = ModelProviderIconApi() @@ -250,11 +406,14 @@ def test_update_success(self, app: Flask): patch( "controllers.console.workspace.model_providers.ModelProviderService.switch_preferred_provider", return_value=None, - ), + ) as switch_preferred_provider, ): result = method(api, "tenant1", provider="openai") - assert result["result"] == "success" + switch_preferred_provider.assert_called_once_with( + tenant_id="tenant1", provider="openai", preferred_provider_type="custom" + ) + assert result == {"result": "success"} def test_invalid_enum(self, app: Flask): api = PreferredProviderTypeUpdateApi() @@ -272,22 +431,29 @@ def test_checkout_success(self, app: Flask): api = ModelProviderPaymentCheckoutUrlApi() method = unwrap(api.get) - user = MagicMock(id="u1", email="x@test.com") + user = make_account() with ( app.test_request_context("/"), patch( "controllers.console.workspace.model_providers.BillingService.is_tenant_owner_or_admin", return_value=None, - ), + ) as is_tenant_owner_or_admin, patch( "controllers.console.workspace.model_providers.BillingService.get_model_provider_payment_link", - return_value={"url": "x"}, - ), + return_value={"payment_link": "https://payment.example.com/provider"}, + ) as get_model_provider_payment_link, ): result = method(api, "tenant1", user, provider="anthropic") - assert "url" in result + is_tenant_owner_or_admin.assert_called_once_with(user) + get_model_provider_payment_link.assert_called_once_with( + provider_name="anthropic", + tenant_id="tenant1", + account_id="account-1", + prefilled_email="owner@example.com", + ) + assert result == {"payment_link": "https://payment.example.com/provider"} def test_invalid_provider(self, app: Flask): api = ModelProviderPaymentCheckoutUrlApi() @@ -295,13 +461,13 @@ def test_invalid_provider(self, app: Flask): with app.test_request_context("/"): with pytest.raises(ValueError): - method(api, "tenant1", MagicMock(), provider="openai") + method(api, "tenant1", make_account(), provider="openai") def test_permission_denied(self, app: Flask): api = ModelProviderPaymentCheckoutUrlApi() method = unwrap(api.get) - user = MagicMock(id="u1", email="x@test.com") + user = make_account() with ( app.test_request_context("/"), diff --git a/api/tests/unit_tests/controllers/console/workspace/test_models.py b/api/tests/unit_tests/controllers/console/workspace/test_models.py index 3374c49caffcad..424c1ef1d75466 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_models.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_models.py @@ -32,7 +32,16 @@ def test_get_success(self, app: Flask): ), patch("controllers.console.workspace.models.ModelProviderService") as service_mock, ): - service_mock.return_value.get_default_model_of_model_type.return_value = {"model": "gpt-4"} + service_mock.return_value.get_default_model_of_model_type.return_value = { + "model": "gpt-4", + "model_type": ModelType.LLM, + "provider": { + "tenant_id": "tenant1", + "provider": "openai", + "label": {"en_US": "OpenAI", "zh_Hans": "OpenAI"}, + "supported_model_types": [ModelType.LLM], + }, + } result = method(api, "tenant1") diff --git a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py index bc76560dcac67b..c4df3c41546ce9 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py @@ -42,7 +42,11 @@ PluginUploadFromGithubApi, PluginUploadFromPkgApi, ) +from core.plugin.entities.parameters import PluginParameterOption +from core.plugin.entities.plugin import PluginDeclaration, PluginEntity, PluginInstallation +from core.plugin.entities.plugin_daemon import PluginInstallTask from core.plugin.impl.exc import PluginDaemonClientSideError +from core.plugin.plugin_service import PluginService from models.account import Account, TenantAccountRole, TenantPluginAutoUpgradeStrategy, TenantPluginPermission @@ -109,6 +113,215 @@ def _builtin_tool_provider_item() -> dict[str, Any]: } +def _plugin_declaration_payload() -> dict[str, Any]: + return { + "version": "1.2.3", + "author": "langgenius", + "name": "demo_plugin", + "description": {"en_US": "Demo plugin"}, + "icon": "icon.svg", + "icon_dark": None, + "label": {"en_US": "Demo Plugin"}, + "created_at": "2024-01-02T03:04:05", + "resource": {"memory": 268435456, "permission": None}, + "plugins": {"tools": ["provider/demo.yaml"]}, + "tags": ["search", "demo"], + "repo": "https://github.com/langgenius/demo", + "verified": True, + "meta": {"minimum_dify_version": "0.15.0", "version": "1.2.3"}, + } + + +def _expected_i18n(en_us: str) -> dict[str, str]: + return {"en_US": en_us, "zh_Hans": en_us, "pt_BR": en_us, "ja_JP": en_us} + + +def _expected_plugin_declaration_dump() -> dict[str, Any]: + return { + "version": "1.2.3", + "author": "langgenius", + "name": "demo_plugin", + "description": _expected_i18n("Demo plugin"), + "icon": "icon.svg", + "icon_dark": None, + "label": _expected_i18n("Demo Plugin"), + "category": "extension", + "created_at": "2024-01-02T03:04:05", + "resource": {"memory": 268435456, "permission": None}, + "plugins": { + "tools": ["provider/demo.yaml"], + "models": [], + "endpoints": [], + "datasources": [], + "triggers": [], + }, + "tags": ["search", "demo"], + "repo": "https://github.com/langgenius/demo", + "verified": True, + "tool": None, + "model": None, + "endpoint": None, + "agent_strategy": None, + "datasource": None, + "trigger": None, + "meta": {"minimum_dify_version": "0.15.0", "version": "1.2.3"}, + } + + +def _plugin_installation_payload() -> dict[str, Any]: + return { + "id": "installation-row-1", + "created_at": "2024-01-02T03:04:05", + "updated_at": "2024-01-03T04:05:06", + "tenant_id": "tenant-1", + "endpoints_setups": 2, + "endpoints_active": 1, + "runtime_type": "remote", + "source": "marketplace", + "meta": {"from": "marketplace"}, + "plugin_id": "langgenius/demo_plugin", + "plugin_unique_identifier": "langgenius/demo_plugin:1.2.3@sha256:abc", + "version": "1.2.3", + "checksum": "sha256:abc", + "declaration": _plugin_declaration_payload(), + } + + +def _plugin_entity_payload() -> dict[str, Any]: + return { + **_plugin_installation_payload(), + "name": "demo_plugin", + "installation_id": "installation-row-1", + } + + +def _plugin_declaration() -> PluginDeclaration: + return PluginDeclaration.model_validate(_plugin_declaration_payload()) + + +def _plugin_installation() -> PluginInstallation: + return PluginInstallation.model_validate(_plugin_installation_payload()) + + +def _plugin_entity() -> PluginEntity: + return PluginEntity.model_validate(_plugin_entity_payload()) + + +def _expected_plugin_installation_dump() -> dict[str, Any]: + return { + "id": "installation-row-1", + "created_at": "2024-01-02T03:04:05", + "updated_at": "2024-01-03T04:05:06", + "tenant_id": "tenant-1", + "endpoints_setups": 2, + "endpoints_active": 1, + "runtime_type": "remote", + "source": "marketplace", + "meta": {"from": "marketplace"}, + "plugin_id": "langgenius/demo_plugin", + "plugin_unique_identifier": "langgenius/demo_plugin:1.2.3@sha256:abc", + "version": "1.2.3", + "checksum": "sha256:abc", + "declaration": _expected_plugin_declaration_dump(), + } + + +def _expected_plugin_entity_dump() -> dict[str, Any]: + return { + **_expected_plugin_installation_dump(), + "name": "demo_plugin", + "installation_id": "installation-row-1", + } + + +def _plugin_task_payload() -> dict[str, Any]: + return { + "id": "task-1", + "created_at": "2024-02-03T04:05:06", + "updated_at": "2024-02-03T04:06:07", + "status": "running", + "total_plugins": 2, + "completed_plugins": 1, + "plugins": [ + { + "plugin_unique_identifier": "langgenius/demo_plugin:1.2.3@sha256:abc", + "plugin_id": "langgenius/demo_plugin", + "status": "success", + "message": "installed", + "icon": "icon.svg", + "labels": {"en_US": "Demo Plugin"}, + "source": "marketplace", + } + ], + } + + +def _plugin_task() -> PluginInstallTask: + return PluginInstallTask.model_validate(_plugin_task_payload()) + + +def _expected_plugin_task_dump() -> dict[str, Any]: + return { + "id": "task-1", + "created_at": "2024-02-03T04:05:06", + "updated_at": "2024-02-03T04:06:07", + "status": "running", + "total_plugins": 2, + "completed_plugins": 1, + "plugins": [ + { + "plugin_unique_identifier": "langgenius/demo_plugin:1.2.3@sha256:abc", + "plugin_id": "langgenius/demo_plugin", + "status": "success", + "message": "installed", + "icon": "icon.svg", + "labels": _expected_i18n("Demo Plugin"), + "source": "marketplace", + } + ], + } + + +def _latest_plugin_cache() -> PluginService.LatestPluginCache: + return PluginService.LatestPluginCache( + plugin_id="langgenius/demo_plugin", + version="1.3.0", + unique_identifier="langgenius/demo_plugin:1.3.0@sha256:def", + status="active", + deprecated_reason="", + alternative_plugin_id="", + ) + + +def _expected_latest_plugin_cache_dump() -> dict[str, str]: + return { + "plugin_id": "langgenius/demo_plugin", + "version": "1.3.0", + "unique_identifier": "langgenius/demo_plugin:1.3.0@sha256:def", + "status": "active", + "deprecated_reason": "", + "alternative_plugin_id": "", + } + + +def _dynamic_option() -> PluginParameterOption: + return PluginParameterOption.model_validate( + { + "value": 101, + "label": {"en_US": "Dataset 101"}, + "icon": None, + } + ) + + +def _expected_dynamic_option_dump() -> dict[str, Any]: + return { + "value": "101", + "label": _expected_i18n("Dataset 101"), + "icon": None, + } + + def _account(role: TenantAccountRole = TenantAccountRole.OWNER) -> Account: account = Account(name="Test User", email="u1@example.com") account.id = "u1" @@ -131,17 +344,24 @@ def test_success(self, app: Flask): api = PluginListLatestVersionsApi() method = unwrap(api.post) - payload = {"plugin_ids": ["p1"]} + payload = {"plugin_ids": ["langgenius/demo_plugin", "langgenius/missing_plugin"]} + versions = { + "langgenius/demo_plugin": _latest_plugin_cache(), + "langgenius/missing_plugin": None, + } with ( app.test_request_context("/", json=payload), - patch( - "controllers.console.workspace.plugin.PluginService.list_latest_versions", return_value={"p1": "1.0"} - ), + patch("controllers.console.workspace.plugin.PluginService.list_latest_versions", return_value=versions), ): result = method(api) - assert "versions" in result + assert result == { + "versions": { + "langgenius/demo_plugin": _expected_latest_plugin_cache_dump(), + "langgenius/missing_plugin": None, + } + } def test_daemon_error(self, app: Flask): api = PluginListLatestVersionsApi() @@ -193,18 +413,18 @@ def test_plugin_list(self, app: Flask): api = PluginListApi() method = unwrap(api.get) - mock_list = MagicMock(list=[{"id": 1}], total=1) + plugins_with_total = MagicMock(list=[_plugin_entity()], total=1) with ( app.test_request_context("/?page=1&page_size=10"), patch( "controllers.console.workspace.plugin.PluginService.list_with_total", - return_value=mock_list, + return_value=plugins_with_total, ) as mock_list_with_total, ): result = method(api, "t1", "u1") - assert result["total"] == 1 + assert result == {"plugins": [_expected_plugin_entity_dump()], "total": 1} mock_list_with_total.assert_called_once_with("t1", "u1", 1, 10) @@ -445,12 +665,12 @@ def test_fetch_dynamic_options(self, app: Flask, user): app.test_request_context("/?plugin_id=p&provider=x&action=y¶meter=z&provider_type=tool"), patch( "controllers.console.workspace.plugin.PluginParameterService.get_dynamic_select_options", - return_value=[1, 2], + return_value=[_dynamic_option()], ), ): result = method(api, "t1", user) - assert result["options"] == [1, 2] + assert result == {"options": [_expected_dynamic_option_dump()]} class TestPluginReadmeApi: @@ -472,18 +692,18 @@ def test_success(self, app: Flask, user): api = PluginListInstallationsFromIdsApi() method = unwrap(api.post) - payload = {"plugin_ids": ["p1", "p2"]} + payload = {"plugin_ids": ["langgenius/demo_plugin"]} with ( app.test_request_context("/", json=payload), patch( "controllers.console.workspace.plugin.PluginService.list_installations_from_ids", - return_value=[{"id": "p1"}], + return_value=[_plugin_installation()], ), ): result = method(api, "t1") - assert "plugins" in result + assert result == {"plugins": [_expected_plugin_installation_dump()]} def test_daemon_error(self, app: Flask): api = PluginListInstallationsFromIdsApi() @@ -669,11 +889,14 @@ def test_success(self, app: Flask): with ( app.test_request_context("/?plugin_unique_identifier=p"), - patch("controllers.console.workspace.plugin.PluginService.fetch_marketplace_pkg", return_value={"m": 1}), + patch( + "controllers.console.workspace.plugin.PluginService.fetch_marketplace_pkg", + return_value=_plugin_declaration(), + ), ): result = method(api, "t1") - assert "manifest" in result + assert result == {"manifest": _expected_plugin_declaration_dump()} def test_daemon_error(self, app: Flask): api = PluginFetchMarketplacePkgApi() @@ -695,8 +918,7 @@ def test_success(self, app: Flask): api = PluginFetchManifestApi() method = unwrap(api.get) - manifest = MagicMock() - manifest.model_dump.return_value = {"x": 1} + manifest = _plugin_declaration() with ( app.test_request_context("/?plugin_unique_identifier=p"), @@ -704,7 +926,7 @@ def test_success(self, app: Flask): ): result = method(api, "t1") - assert "manifest" in result + assert result == {"manifest": _expected_plugin_declaration_dump()} def test_daemon_error(self, app: Flask): api = PluginFetchManifestApi() @@ -728,11 +950,14 @@ def test_success(self, app: Flask): with ( app.test_request_context("/?page=1&page_size=10"), - patch("controllers.console.workspace.plugin.PluginService.fetch_install_tasks", return_value=[{"id": 1}]), + patch( + "controllers.console.workspace.plugin.PluginService.fetch_install_tasks", + return_value=[_plugin_task()], + ), ): result = method(api, "t1") - assert "tasks" in result + assert result == {"tasks": [_expected_plugin_task_dump()]} def test_daemon_error(self, app: Flask): api = PluginFetchInstallTasksApi() @@ -756,11 +981,11 @@ def test_success(self, app: Flask): with ( app.test_request_context("/"), - patch("controllers.console.workspace.plugin.PluginService.fetch_install_task", return_value={"id": "x"}), + patch("controllers.console.workspace.plugin.PluginService.fetch_install_task", return_value=_plugin_task()), ): result = method(api, "t1", "x") - assert "task" in result + assert result == {"task": _expected_plugin_task_dump()} def test_daemon_error(self, app: Flask): api = PluginFetchInstallTaskApi() @@ -969,12 +1194,12 @@ def test_success(self, app: Flask, user): app.test_request_context("/", json=payload), patch( "controllers.console.workspace.plugin.PluginParameterService.get_dynamic_select_options_with_credentials", - return_value=[1], + return_value=[_dynamic_option()], ), ): result = method(api, "t1", user) - assert result["options"] == [1] + assert result == {"options": [_expected_dynamic_option_dump()]} def test_daemon_error(self, app: Flask, user): api = PluginFetchDynamicSelectOptionsWithCredentialsApi() diff --git a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py index e8e005a1b834a1..55eee935606d4e 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py @@ -1,3 +1,4 @@ +from datetime import datetime from inspect import unwrap from types import SimpleNamespace from unittest.mock import ANY, Mock @@ -46,12 +47,73 @@ def _snippet(**overrides) -> CustomizedSnippet: "name": "Snippet", "description": "Description", "type": snippets_module.SnippetType.NODE, + "version": 3, + "use_count": 7, + "is_published": True, + "icon_info": {"icon": "star", "icon_background": "#101828", "icon_type": "emoji"}, + "input_fields": '[{"label": "Question", "variable": "query", "type": "text-input"}]', "created_by": "account-1", + "created_at": datetime(2024, 1, 2, 3, 4, 5), + "updated_by": None, + "updated_at": datetime(2024, 1, 3, 4, 5, 6), } data.update(overrides) return CustomizedSnippet(**data) +def _patch_snippet_response_properties(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + CustomizedSnippet, + "tags", + property(lambda _snippet: [{"id": "tag-1", "name": "Search", "type": "snippet"}]), + ) + monkeypatch.setattr(CustomizedSnippet, "created_by_account", property(lambda _snippet: _account("account-1"))) + monkeypatch.setattr(CustomizedSnippet, "updated_by_account", property(lambda _snippet: None)) + + +def _expected_snippet_list_item(snippet: CustomizedSnippet) -> dict: + return { + "id": snippet.id, + "name": snippet.name, + "description": snippet.description, + "type": snippet.type, + "version": snippet.version, + "use_count": snippet.use_count, + "is_published": snippet.is_published, + "icon_info": snippet.icon_info, + "tags": [{"id": "tag-1", "name": "Search", "type": "snippet"}], + "created_by": snippet.created_by, + "author_name": "Test User", + "created_at": int(snippet.created_at.timestamp()), + "updated_by": snippet.updated_by, + "updated_at": int(snippet.updated_at.timestamp()), + } + + +def _expected_snippet_response(snippet: CustomizedSnippet) -> dict: + return { + "id": snippet.id, + "name": snippet.name, + "description": snippet.description, + "type": snippet.type, + "version": snippet.version, + "use_count": snippet.use_count, + "is_published": snippet.is_published, + "icon_info": snippet.icon_info, + "graph": {}, + "input_fields": [{"label": "Question", "variable": "query", "type": "text-input"}], + "tags": [{"id": "tag-1", "name": "Search", "type": "snippet"}], + "created_by": { + "id": "account-1", + "name": "Test User", + "email": "account-1@example.com", + }, + "created_at": int(snippet.created_at.timestamp()), + "updated_by": None, + "updated_at": int(snippet.updated_at.timestamp()), + } + + def test_normalize_snippet_list_query_args_sorts_indexed_values(): query_args = snippets_module.MultiDict( [ @@ -75,7 +137,7 @@ def test_list_snippets_returns_pagination(app: Flask, monkeypatch: pytest.Monkey tag_id = "11111111-1111-1111-1111-111111111111" get_snippets = Mock(return_value=(snippets, 1, False)) monkeypatch.setattr(snippets_module.SnippetService, "get_snippets", get_snippets) - monkeypatch.setattr(snippets_module, "marshal", Mock(return_value=[{"id": "snippet-1"}])) + _patch_snippet_response_properties(monkeypatch) api = snippets_module.CustomizedSnippetsApi() handler = unwrap(api.get) @@ -87,7 +149,7 @@ def test_list_snippets_returns_pagination(app: Flask, monkeypatch: pytest.Monkey assert status_code == 200 assert response == { - "data": [{"id": "snippet-1"}], + "data": [_expected_snippet_list_item(snippets[0])], "page": 2, "limit": 10, "total": 1, @@ -110,6 +172,7 @@ def test_create_snippet_defaults_unknown_type_and_returns_created(app: Flask, mo snippet = _snippet() create_snippet = Mock(return_value=snippet) monkeypatch.setattr(snippets_module.SnippetService, "create_snippet", create_snippet) + _patch_snippet_response_properties(monkeypatch) monkeypatch.setattr( snippets_module.CreateSnippetPayload, "model_validate", @@ -124,7 +187,6 @@ def test_create_snippet_defaults_unknown_type_and_returns_created(app: Flask, mo ) ), ) - monkeypatch.setattr(snippets_module, "marshal", Mock(return_value={"id": "snippet-1"})) api = snippets_module.CustomizedSnippetsApi() handler = unwrap(api.post) @@ -137,7 +199,7 @@ def test_create_snippet_defaults_unknown_type_and_returns_created(app: Flask, mo response, status_code = handler(api, "tenant-1", user) assert status_code == 201 - assert response == {"id": "snippet-1"} + assert response == _expected_snippet_response(snippet) assert create_snippet.call_args.kwargs["snippet_type"] == snippets_module.SnippetType.NODE @@ -184,7 +246,7 @@ def test_get_snippet_detail_raises_when_missing(app: Flask, monkeypatch: pytest. def test_get_snippet_detail_returns_snippet(app: Flask, monkeypatch: pytest.MonkeyPatch): snippet = _snippet() monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) - monkeypatch.setattr(snippets_module, "marshal", Mock(return_value={"id": "snippet-1"})) + _patch_snippet_response_properties(monkeypatch) api = snippets_module.CustomizedSnippetDetailApi() handler = unwrap(api.get) @@ -193,7 +255,7 @@ def test_get_snippet_detail_returns_snippet(app: Flask, monkeypatch: pytest.Monk response, status_code = handler(api, "tenant-1", snippet_id="snippet-1") assert status_code == 200 - assert response == {"id": "snippet-1"} + assert response == _expected_snippet_response(snippet) def test_patch_snippet_returns_400_for_empty_payload(app: Flask, monkeypatch: pytest.MonkeyPatch): @@ -230,7 +292,7 @@ def __init__(self, engine, *args, **kwargs): monkeypatch.setattr(snippets_module.SnippetService, "update_snippet", update_snippet) monkeypatch.setattr(snippets_module, "Session", SessionContext) monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(snippets_module, "marshal", Mock(return_value={"id": "snippet-1", "name": "New"})) + _patch_snippet_response_properties(monkeypatch) api = snippets_module.CustomizedSnippetDetailApi() handler = unwrap(api.patch) @@ -243,7 +305,7 @@ def __init__(self, engine, *args, **kwargs): response, status_code = handler(api, "tenant-1", user, snippet_id="snippet-1") assert status_code == 200 - assert response == {"id": "snippet-1", "name": "New"} + assert response == _expected_snippet_response(updated_snippet) update_snippet.assert_called_once() assert update_snippet.call_args.kwargs["data"] == { "name": "New", diff --git a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py index e9c23da428ed84..f471915781a439 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py @@ -5,7 +5,6 @@ import builtins import importlib from contextlib import ExitStack, contextmanager -from inspect import unwrap from types import ModuleType, SimpleNamespace from unittest.mock import MagicMock, patch @@ -13,6 +12,9 @@ from flask import Flask from flask.views import MethodView +from core.tools.entities.api_entities import ToolProviderApiEntity as CoreToolProviderApiEntity +from core.tools.entities.common_entities import I18nObject +from core.tools.entities.tool_entities import ToolParameter from models import Account from models.account import TenantAccountRole @@ -21,6 +23,7 @@ _CONTROLLER_MODULE: ModuleType | None = None +_WRAPS_MODULE: ModuleType | None = None @contextmanager @@ -69,10 +72,11 @@ def _noop(func): _CONTROLLER_MODULE = importlib.import_module(module_name) module = _CONTROLLER_MODULE - monkeypatch.setattr(module, "jsonable_encoder", lambda payload: payload) # Ensure decorators that consult deployment edition do not reach the database. + global _WRAPS_MODULE wraps_module = importlib.import_module("controllers.console.wraps") + _WRAPS_MODULE = wraps_module monkeypatch.setattr(module.dify_config, "EDITION", "CLOUD") monkeypatch.setattr(wraps_module.dify_config, "EDITION", "CLOUD") @@ -88,19 +92,194 @@ def _mock_account(user_id: str = "user-123") -> Account: return user +def _set_current_account( + monkeypatch: pytest.MonkeyPatch, + controller_module: ModuleType, + user: Account, + tenant_id: str, +) -> None: + def _getter(): + return user, tenant_id + + monkeypatch.setattr(controller_module, "current_account_with_tenant", _getter, raising=False) + if _WRAPS_MODULE is not None: + monkeypatch.setattr(_WRAPS_MODULE, "current_account_with_tenant", _getter) + + login_module = importlib.import_module("libs.login") + monkeypatch.setattr(login_module, "_get_user", lambda: user) + + +def _i18n(text: str) -> dict[str, str]: + return {"en_US": text, "zh_Hans": text, "pt_BR": text, "ja_JP": text} + + +def _tool_response(controller_module: ModuleType, name: str = "tool-a") -> tuple[dict, dict]: + expected = { + "author": "Dify", + "name": name, + "label": _i18n(name), + "description": _i18n(f"{name} description"), + "parameters": [], + "labels": [], + "output_schema": {}, + } + tool = controller_module.ToolApiEntity.model_validate(expected) + return tool.model_dump(mode="json"), expected + + +def _provider_entity_response( + controller_module: ModuleType, name: str = "provider", provider_type: str = "builtin" +) -> tuple[CoreToolProviderApiEntity, dict]: + service_payload = { + "id": f"{name}-id", + "author": "Dify", + "name": name, + "description": _i18n(f"{name} description"), + "icon": "tool.svg", + "icon_dark": "", + "label": _i18n(name), + "type": provider_type, + "masked_credentials": {"api_key": "[__HIDDEN__]"}, + "original_credentials": {"api_key": "sk-secret"}, + "is_team_authorization": False, + "allow_delete": True, + "plugin_id": "", + "plugin_unique_identifier": "", + "tools": [], + "labels": [], + "server_url": "", + "updated_at": 1, + "server_identifier": "", + "masked_headers": None, + "original_headers": None, + "authentication": None, + "is_dynamic_registration": True, + "configuration": None, + "identity_mode": "off", + "workflow_app_id": None, + } + provider = CoreToolProviderApiEntity.model_validate(service_payload) + return provider, provider.to_dict() + + +def _provider_list_item( + controller_module: ModuleType, name: str = "provider", provider_type: str = "builtin" +) -> tuple[dict, dict]: + service_payload = { + "id": f"{name}-id", + "author": "Dify", + "name": name, + "description": _i18n(f"{name} description"), + "icon": "tool.svg", + "icon_dark": "", + "label": _i18n(name), + "type": provider_type, + "team_credentials": {"api_key": "[__HIDDEN__]"}, + "is_team_authorization": False, + "allow_delete": True, + "plugin_id": "", + "plugin_unique_identifier": "", + "tools": [], + "labels": [], + } + expected = { + **service_payload, + } + provider = controller_module.ToolProviderApiEntityResponse.model_validate(expected) + return service_payload, provider.model_dump(mode="json", exclude_unset=True) + + +def _credential_response(controller_module: ModuleType, credential_id: str = "cred-1") -> tuple[dict, dict]: + expected = { + "id": credential_id, + "name": "Credential", + "provider": "demo", + "credential_type": controller_module.CredentialType.API_KEY, + "is_default": False, + "credentials": {}, + "visibility": "all_team_members", + "created_by": "", + "partial_member_list": [], + "from_other_member": False, + } + credential = controller_module.ToolProviderCredentialApiEntity.model_validate(expected) + return credential.model_dump(mode="json"), credential.model_dump(mode="json") + + +def _provider_config_response(controller_module: ModuleType) -> tuple[dict, dict]: + expected = { + "type": "secret-input", + "name": "api_key", + "scope": None, + "required": False, + "default": None, + "options": None, + "multiple": False, + "label": None, + "help": None, + "url": None, + "placeholder": None, + } + config = controller_module.ProviderConfig.model_validate(expected) + return config.model_dump(mode="json"), expected + + +def _api_provider_detail_response(controller_module: ModuleType) -> tuple[dict, dict]: + expected = { + "schema_type": "openapi", + "schema": "{}", + "tools": [], + "icon": {"background": "#252525", "content": "tool"}, + "description": "provider description", + "credentials": {"auth_type": "none"}, + "privacy_policy": "", + "custom_disclaimer": "", + "labels": [], + } + detail = controller_module.ApiProviderDetailResponse.model_validate(expected) + return detail.model_dump(mode="json", by_alias=True), expected + + +def _workflow_detail_response(controller_module: ModuleType) -> tuple[dict, dict]: + tool_payload, tool_expected = _tool_response(controller_module, "workflow-tool") + expected = { + "name": "workflow-tool", + "label": "Workflow Tool", + "workflow_tool_id": "00000000-0000-0000-0000-000000000001", + "workflow_app_id": "00000000-0000-0000-0000-000000000002", + "icon": {"background": "#252525", "content": "tool"}, + "description": "description", + "parameters": [], + "output_schema": {}, + "tool": tool_expected, + "synced": True, + "privacy_policy": "", + } + service_payload = {**expected, "tool": tool_payload} + detail = controller_module.WorkflowToolDetailResponse.model_validate(service_payload) + return detail.model_dump(mode="json"), expected + + +def _tool_label_response(controller_module: ModuleType, name: str = "search") -> tuple[dict, dict]: + expected = {"name": name, "label": _i18n(name), "icon": "search"} + label = controller_module.ToolLabel.model_validate(expected) + return label.model_dump(mode="json"), expected + + def test_tool_provider_list_calls_service_with_query( app: Flask, controller_module: ModuleType, monkeypatch: pytest.MonkeyPatch ): user = _mock_account() + _set_current_account(monkeypatch, controller_module, user, "tenant-456") - service_mock = MagicMock(return_value=[{"provider": "builtin"}]) + service_payload, expected_response = _provider_list_item(controller_module, "builtin", "builtin") + service_mock = MagicMock(return_value=[service_payload]) monkeypatch.setattr(controller_module.ToolCommonService, "list_tool_providers", service_mock) with app.test_request_context("/workspaces/current/tool-providers?type=builtin"): - api = controller_module.ToolProviderListApi() - response = unwrap(api.get)(api, "tenant-456", user) + response = controller_module.ToolProviderListApi().get() - assert response == [{"provider": "builtin"}] + assert response == [expected_response] service_mock.assert_called_once_with(user.id, "tenant-456", "builtin") @@ -108,8 +287,9 @@ def test_builtin_provider_add_passes_payload( app: Flask, controller_module: ModuleType, monkeypatch: pytest.MonkeyPatch ): user = _mock_account() + _set_current_account(monkeypatch, controller_module, user, "tenant-456") - service_mock = MagicMock(return_value={"status": "ok"}) + service_mock = MagicMock(return_value={"result": "success"}) monkeypatch.setattr(controller_module.BuiltinToolManageService, "add_builtin_tool_provider", service_mock) payload = { @@ -123,10 +303,9 @@ def test_builtin_provider_add_passes_payload( method="POST", json=payload, ): - api = controller_module.ToolBuiltinProviderAddApi() - response = unwrap(api.post)(api, "tenant-456", user, provider="openai") + response = controller_module.ToolBuiltinProviderAddApi().post(provider="openai") - assert response == {"status": "ok"} + assert response == {"result": "success"} service_mock.assert_called_once_with( user_id="user-123", tenant_id="tenant-456", @@ -140,38 +319,88 @@ def test_builtin_provider_add_passes_payload( def test_builtin_provider_tools_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account("user-tenant-789") + _set_current_account(monkeypatch, controller_module, user, "tenant-789") - service_mock = MagicMock(return_value=[{"name": "tool-a"}]) + service_payload, expected_response = _tool_response(controller_module, "tool-a") + service_mock = MagicMock(return_value=[service_payload]) monkeypatch.setattr(controller_module.BuiltinToolManageService, "list_builtin_tool_provider_tools", service_mock) - monkeypatch.setattr(controller_module, "jsonable_encoder", lambda payload: payload) with app.test_request_context( "/workspaces/current/tool-provider/builtin/my-provider/tools", method="GET", ): - api = controller_module.ToolBuiltinProviderListToolsApi() - response = unwrap(api.get)(api, "tenant-789", provider="my-provider") + response = controller_module.ToolBuiltinProviderListToolsApi().get(provider="my-provider") - assert response == [{"name": "tool-a"}] + assert response == [expected_response] service_mock.assert_called_once_with("tenant-789", "my-provider") def test_builtin_provider_info_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account("user-tenant-9") - service_mock = MagicMock(return_value={"info": True}) + _set_current_account(monkeypatch, controller_module, user, "tenant-9") + service_payload, expected_response = _provider_entity_response(controller_module, "demo", "builtin") + service_mock = MagicMock(return_value=service_payload) monkeypatch.setattr(controller_module.BuiltinToolManageService, "get_builtin_tool_provider_info", service_mock) with app.test_request_context("/info", method="GET"): - api = controller_module.ToolBuiltinProviderInfoApi() - resp = unwrap(api.get)(api, "tenant-9", provider="demo") + resp = controller_module.ToolBuiltinProviderInfoApi().get(provider="demo") - assert resp == {"info": True} + assert resp == expected_response service_mock.assert_called_once_with("tenant-9", "demo") +def test_builtin_provider_info_uses_core_to_dict_tool_projection( + app: Flask, controller_module: ModuleType, monkeypatch: pytest.MonkeyPatch +): + user = _mock_account("user-tenant-9") + _set_current_account(monkeypatch, controller_module, user, "tenant-9") + tool_parameter = ToolParameter( + name="system_files", + label=I18nObject(en_US="System Files", zh_Hans="System Files"), + type=ToolParameter.ToolParameterType.SYSTEM_FILES, + form=ToolParameter.ToolParameterForm.LLM, + input_schema=None, + ) + tool = controller_module.ToolApiEntity( + author="Dify", + name="demo-tool", + label=I18nObject(en_US="Demo Tool", zh_Hans="Demo Tool"), + description=I18nObject(en_US="Demo Tool description", zh_Hans="Demo Tool description"), + parameters=[tool_parameter], + labels=[], + output_schema={}, + ) + provider = CoreToolProviderApiEntity( + id="demo-id", + author="Dify", + name="demo", + description=I18nObject(en_US="demo description", zh_Hans="demo description"), + icon="tool.svg", + label=I18nObject(en_US="demo", zh_Hans="demo"), + type=controller_module.ToolProviderType.BUILT_IN, + masked_credentials={"api_key": "[__HIDDEN__]"}, + original_credentials={"api_key": "sk-secret"}, + tools=[tool], + ) + service_mock = MagicMock(return_value=provider) + monkeypatch.setattr(controller_module.BuiltinToolManageService, "get_builtin_tool_provider_info", service_mock) + + with app.test_request_context("/info", method="GET"): + resp = controller_module.ToolBuiltinProviderInfoApi().get(provider="demo") + + parameter = resp["tools"][0]["parameters"][0] + assert parameter["type"] == "files" + assert parameter["input_schema"] is None + assert resp["team_credentials"] == {"api_key": "[__HIDDEN__]"} + assert "masked_credentials" not in resp + assert "original_credentials" not in resp + + def test_builtin_provider_credentials_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account("user-tenant-cred") - service_mock = MagicMock(return_value=[{"cred": 1}]) + _set_current_account(monkeypatch, controller_module, user, "tenant-cred") + service_payload, expected_response = _credential_response(controller_module) + service_mock = MagicMock(return_value=[service_payload]) monkeypatch.setattr( controller_module.BuiltinToolManageService, "get_builtin_tool_provider_credentials", @@ -179,10 +408,9 @@ def test_builtin_provider_credentials_get(app: Flask, controller_module, monkeyp ) with app.test_request_context("/creds", method="GET"): - api = controller_module.ToolBuiltinProviderGetCredentialsApi() - resp = unwrap(api.get)(api, "tenant-cred", user, provider="demo") + resp = controller_module.ToolBuiltinProviderGetCredentialsApi().get(provider="demo") - assert resp == [{"cred": 1}] + assert resp == [expected_response] service_mock.assert_called_once_with( tenant_id="tenant-cred", provider_name="demo", @@ -193,46 +421,51 @@ def test_builtin_provider_credentials_get(app: Flask, controller_module, monkeyp def test_api_provider_remote_schema_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account() - service_mock = MagicMock(return_value={"schema": "ok"}) + _set_current_account(monkeypatch, controller_module, user, "tenant-10") + openapi_schema = '{"openapi":"3.0.0","info":{"title":"Demo API","version":"1.0.0"},"paths":{}}' + service_mock = MagicMock(return_value={"schema": openapi_schema}) monkeypatch.setattr(controller_module.ApiToolManageService, "get_api_tool_provider_remote_schema", service_mock) with app.test_request_context("/remote?url=https://example.com/"): - api = controller_module.ToolApiProviderGetRemoteSchemaApi() - resp = unwrap(api.get)(api, "tenant-10", user) + resp = controller_module.ToolApiProviderGetRemoteSchemaApi().get() - assert resp == {"schema": "ok"} + assert resp == {"schema": openapi_schema} service_mock.assert_called_once_with(user.id, "tenant-10", "https://example.com/") def test_api_provider_list_tools_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account() - service_mock = MagicMock(return_value=[{"tool": "t"}]) + _set_current_account(monkeypatch, controller_module, user, "tenant-11") + service_payload, expected_response = _tool_response(controller_module, "t") + service_mock = MagicMock(return_value=[service_payload]) monkeypatch.setattr(controller_module.ApiToolManageService, "list_api_tool_provider_tools", service_mock) with app.test_request_context("/tools?provider=foo"): - api = controller_module.ToolApiProviderListToolsApi() - resp = unwrap(api.get)(api, "tenant-11", user) + resp = controller_module.ToolApiProviderListToolsApi().get() - assert resp == [{"tool": "t"}] + assert resp == [expected_response] service_mock.assert_called_once_with(user.id, "tenant-11", "foo") def test_api_provider_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account() - service_mock = MagicMock(return_value={"provider": "foo"}) + _set_current_account(monkeypatch, controller_module, user, "tenant-12") + service_payload, expected_response = _api_provider_detail_response(controller_module) + service_mock = MagicMock(return_value=service_payload) monkeypatch.setattr(controller_module.ApiToolManageService, "get_api_tool_provider", service_mock) with app.test_request_context("/get?provider=foo"): - api = controller_module.ToolApiProviderGetApi() - resp = unwrap(api.get)(api, "tenant-12", user) + resp = controller_module.ToolApiProviderGetApi().get() - assert resp == {"provider": "foo"} + assert resp == expected_response service_mock.assert_called_once_with(user.id, "tenant-12", "foo") def test_builtin_provider_credentials_schema_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account("user-tenant-13") - service_mock = MagicMock(return_value={"schema": True}) + _set_current_account(monkeypatch, controller_module, user, "tenant-13") + service_payload, expected_response = _provider_config_response(controller_module) + service_mock = MagicMock(return_value=[service_payload]) monkeypatch.setattr( controller_module.BuiltinToolManageService, "list_builtin_provider_credentials_schema", @@ -240,16 +473,19 @@ def test_builtin_provider_credentials_schema_get(app: Flask, controller_module, ) with app.test_request_context("/schema", method="GET"): - api = controller_module.ToolBuiltinProviderCredentialsSchemaApi() - resp = unwrap(api.get)(api, "tenant-13", provider="demo", credential_type="api-key") + resp = controller_module.ToolBuiltinProviderCredentialsSchemaApi().get( + provider="demo", credential_type="api-key" + ) - assert resp == {"schema": True} + assert resp == [expected_response] service_mock.assert_called_once() def test_workflow_provider_get_by_tool(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account() - tool_service = MagicMock(return_value={"wf": 1}) + _set_current_account(monkeypatch, controller_module, user, "tenant-wf") + service_payload, expected_response = _workflow_detail_response(controller_module) + tool_service = MagicMock(return_value=service_payload) monkeypatch.setattr( controller_module.WorkflowToolManageService, "get_workflow_tool_by_tool_id", @@ -258,16 +494,17 @@ def test_workflow_provider_get_by_tool(app: Flask, controller_module, monkeypatc tool_id = "00000000-0000-0000-0000-000000000001" with app.test_request_context(f"/workflow?workflow_tool_id={tool_id}"): - api = controller_module.ToolWorkflowProviderGetApi() - resp = unwrap(api.get)(api, "tenant-wf", user) + resp = controller_module.ToolWorkflowProviderGetApi().get() - assert resp == {"wf": 1} + assert resp == expected_response tool_service.assert_called_once_with(user.id, "tenant-wf", tool_id) def test_workflow_provider_get_by_app(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account() - service_mock = MagicMock(return_value={"app": 1}) + _set_current_account(monkeypatch, controller_module, user, "tenant-wf2") + service_payload, expected_response = _workflow_detail_response(controller_module) + service_mock = MagicMock(return_value=service_payload) monkeypatch.setattr( controller_module.WorkflowToolManageService, "get_workflow_tool_by_app_id", @@ -276,31 +513,32 @@ def test_workflow_provider_get_by_app(app: Flask, controller_module, monkeypatch app_id = "00000000-0000-0000-0000-000000000002" with app.test_request_context(f"/workflow?workflow_app_id={app_id}"): - api = controller_module.ToolWorkflowProviderGetApi() - resp = unwrap(api.get)(api, "tenant-wf2", user) + resp = controller_module.ToolWorkflowProviderGetApi().get() - assert resp == {"app": 1} + assert resp == expected_response service_mock.assert_called_once_with(user.id, "tenant-wf2", app_id) def test_workflow_provider_list_tools(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account() - service_mock = MagicMock(return_value=[{"id": 1}]) + _set_current_account(monkeypatch, controller_module, user, "tenant-wf3") + service_payload, expected_response = _tool_response(controller_module, "workflow-tool") + service_mock = MagicMock(return_value=[service_payload]) monkeypatch.setattr(controller_module.WorkflowToolManageService, "list_single_workflow_tools", service_mock) tool_id = "00000000-0000-0000-0000-000000000003" with app.test_request_context(f"/workflow/tools?workflow_tool_id={tool_id}"): - api = controller_module.ToolWorkflowProviderListToolApi() - resp = unwrap(api.get)(api, "tenant-wf3", user) + resp = controller_module.ToolWorkflowProviderListToolApi().get() - assert resp == [{"id": 1}] + assert resp == [expected_response] service_mock.assert_called_once_with(user.id, "tenant-wf3", tool_id) def test_builtin_tools_list(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account() + _set_current_account(monkeypatch, controller_module, user, "tenant-bt") - provider = SimpleNamespace(to_dict=lambda: {"name": "builtin"}) + provider, expected_response = _provider_entity_response(controller_module, "builtin", "builtin") monkeypatch.setattr( controller_module.BuiltinToolManageService, "list_builtin_tools", @@ -308,16 +546,16 @@ def test_builtin_tools_list(app: Flask, controller_module, monkeypatch: pytest.M ) with app.test_request_context("/tools/builtin"): - api = controller_module.ToolBuiltinListApi() - resp = unwrap(api.get)(api, "tenant-bt", user) + resp = controller_module.ToolBuiltinListApi().get() - assert resp == [{"name": "builtin"}] + assert resp == [expected_response] def test_api_tools_list(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account("user-tenant-api") + _set_current_account(monkeypatch, controller_module, user, "tenant-api") - provider = SimpleNamespace(to_dict=lambda: {"name": "api"}) + provider, expected_response = _provider_entity_response(controller_module, "api", "api") monkeypatch.setattr( controller_module.ApiToolManageService, "list_api_tools", @@ -325,16 +563,16 @@ def test_api_tools_list(app: Flask, controller_module, monkeypatch: pytest.Monke ) with app.test_request_context("/tools/api"): - api = controller_module.ToolApiListApi() - resp = unwrap(api.get)(api, "tenant-api") + resp = controller_module.ToolApiListApi().get() - assert resp == [{"name": "api"}] + assert resp == [expected_response] def test_workflow_tools_list(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): user = _mock_account() + _set_current_account(monkeypatch, controller_module, user, "tenant-wf4") - provider = SimpleNamespace(to_dict=lambda: {"name": "wf"}) + provider, expected_response = _provider_entity_response(controller_module, "wf", "workflow") monkeypatch.setattr( controller_module.WorkflowToolManageService, "list_tenant_workflow_tools", @@ -342,20 +580,21 @@ def test_workflow_tools_list(app: Flask, controller_module, monkeypatch: pytest. ) with app.test_request_context("/tools/workflow"): - api = controller_module.ToolWorkflowListApi() - resp = unwrap(api.get)(api, "tenant-wf4", user) + resp = controller_module.ToolWorkflowListApi().get() - assert resp == [{"name": "wf"}] + assert resp == [expected_response] def test_tool_labels_list(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(controller_module.ToolLabelsService, "list_tool_labels", lambda: ["a", "b"]) + user = _mock_account("user-label") + _set_current_account(monkeypatch, controller_module, user, "tenant-labels") + service_payload, expected_response = _tool_label_response(controller_module, "a") + monkeypatch.setattr(controller_module.ToolLabelsService, "list_tool_labels", lambda: [service_payload]) with app.test_request_context("/tool-labels"): - api = controller_module.ToolLabelsApi() - resp = unwrap(api.get)(api) + resp = controller_module.ToolLabelsApi().get() - assert resp == ["a", "b"] + assert resp == [expected_response] # --- _resolve_identity_mode: gating + None-resolution (PR #36839 review) --- diff --git a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py index 47e9f51fb27b84..e9cd3410cb9dc0 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -26,7 +26,9 @@ WebappLogoWorkspaceApi, WorkspaceInfoApi, WorkspaceListApi, + WorkspaceLogoUploadResponse, WorkspacePermissionApi, + WorkspacePermissionResponse, ) from enums.cloud_plan import CloudPlan from libs.datetime_utils import naive_utc_now @@ -587,7 +589,8 @@ def test_upload_success(self, app: Flask): result, status = method(api, user) assert status == 201 - assert result["id"] == "file1" + assert result == {"id": "file1"} + assert WorkspaceLogoUploadResponse.model_validate(result).model_dump(mode="json") == {"id": "file1"} def test_filename_missing(self, app: Flask): api = WebappLogoWorkspaceApi() @@ -676,7 +679,7 @@ def test_post_success(self, app: Flask): patch("controllers.console.workspace.workspace.db.session.commit"), patch( "controllers.console.workspace.workspace.WorkspaceService.get_tenant_info", - return_value={"name": "New Name"}, + return_value={"id": "t1", "name": "New Name"}, ), ): result = method(api, "t1") @@ -717,7 +720,13 @@ def test_get_success(self, app: Flask): result, status = method(api, "t1") assert status == 200 - assert result["workspace_id"] == "t1" + expected = { + "workspace_id": "t1", + "allow_member_invite": True, + "allow_owner_transfer": False, + } + assert result == expected + assert WorkspacePermissionResponse.model_validate(result).model_dump(mode="json") == expected def test_no_current_tenant(self, app: Flask): api = WorkspacePermissionApi() diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py index ddd72f604d6891..aabed01262c12e 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py @@ -3,13 +3,36 @@ from __future__ import annotations import sys -from types import SimpleNamespace +import uuid from unittest.mock import Mock import pytest from flask import Flask from controllers.openapi._models import AppRunRequest +from models import Account +from models.model import App, AppMode + +_TEST_APP_ID = str(uuid.uuid4()) +_TEST_TENANT_ID = str(uuid.uuid4()) +_TEST_ACCOUNT_ID = str(uuid.uuid4()) + + +def _make_app() -> App: + app = App() + app.id = _TEST_APP_ID + app.tenant_id = _TEST_TENANT_ID + app.name = "Streaming app" + app.mode = AppMode.CHAT + app.enable_site = False + app.enable_api = True + return app + + +def _make_account() -> Account: + account = Account(name="OpenAPI caller", email="caller@example.com") + account.id = _TEST_ACCOUNT_ID + return account def test_app_run_request_has_no_response_mode_field(): @@ -40,15 +63,19 @@ def test_run_chat_always_calls_generate_with_streaming_true( from controllers.openapi.app_run import _run_chat generate_mock = Mock(return_value=iter([])) + + class GenerateService: + generate = generate_mock + monkeypatch.setattr( sys.modules["controllers.openapi.app_run"], "AppGenerateService", - SimpleNamespace(generate=generate_mock), + GenerateService, ) - with app.test_request_context("/openapi/v1/apps/app-1/run", method="POST"): + with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/run", method="POST"): _run_chat( - SimpleNamespace(id="app-1", tenant_id="t-1"), - SimpleNamespace(id="acct-1"), + _make_app(), + _make_account(), AppRunRequest(inputs={}, query="hello"), ) _, kwargs = generate_mock.call_args @@ -80,11 +107,11 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipel auth_data = AuthData.model_construct( token_type=TokenType.OAUTH_ACCOUNT, - account_id=uuid.uuid4(), + account_id=uuid.UUID(_TEST_ACCOUNT_ID), token_hash="test", scopes=frozenset({Scope.FULL}), - app=SimpleNamespace(id="app-1", tenant_id="t-1"), - caller=SimpleNamespace(id="acct-1"), + app=_make_app(), + caller=_make_account(), caller_kind="account", ) diff --git a/api/tests/unit_tests/controllers/openapi/test_contract.py b/api/tests/unit_tests/controllers/openapi/test_contract.py index b8773f56df87e0..69263a5d92917a 100644 --- a/api/tests/unit_tests/controllers/openapi/test_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_contract.py @@ -5,6 +5,7 @@ """ from functools import wraps +from typing import Any, cast import pytest from pydantic import BaseModel, ConfigDict, Field @@ -100,7 +101,7 @@ def view(*, body): with pytest.raises(UnprocessableEntity) as exc_info: view() - data = exc_info.value.data + data = cast(dict[str, Any], cast(Any, exc_info.value).data) assert data["message"] == "Request validation failed" assert isinstance(data["errors"], list) assert data["errors"] diff --git a/api/tests/unit_tests/controllers/service_api/app/test_annotation.py b/api/tests/unit_tests/controllers/service_api/app/test_annotation.py index b4dd5e957c169a..94128e276146c3 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_annotation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_annotation.py @@ -141,14 +141,14 @@ def test_app_model_has_required_fields(self): assert app.id is not None assert app.status == "normal" - assert app.enable_api is True + assert app.enable_api def test_app_model_disabled_api(self): """Test app with disabled API access.""" app = Mock(spec=App) app.enable_api = False - assert app.enable_api is False + assert not app.enable_api def test_app_model_archived_status(self): """Test app with archived status.""" @@ -183,7 +183,7 @@ def test_value_error_for_job_not_found(self): class TestAnnotationReplyActionApi: def test_enable(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - enable_mock = Mock() + enable_mock = Mock(return_value={"job_id": "job-1", "job_status": "waiting"}) monkeypatch.setattr(AppAnnotationService, "enable_app_annotation", enable_mock) api = AnnotationReplyActionApi() @@ -198,10 +198,11 @@ def test_enable(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: response, status = handler(api, app_model=app_model, action="enable") assert status == 200 + assert response == {"job_id": "job-1", "job_status": "waiting"} enable_mock.assert_called_once() def test_disable(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - disable_mock = Mock() + disable_mock = Mock(return_value={"job_id": "job-1", "job_status": "waiting"}) monkeypatch.setattr(AppAnnotationService, "disable_app_annotation", disable_mock) api = AnnotationReplyActionApi() @@ -216,6 +217,7 @@ def test_disable(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: response, status = handler(api, app_model=app_model, action="disable") assert status == 200 + assert response == {"job_id": "job-1", "job_status": "waiting"} disable_mock.assert_called_once() diff --git a/api/tests/unit_tests/controllers/service_api/app/test_message.py b/api/tests/unit_tests/controllers/service_api/app/test_message.py index d8d5c61bcb34bf..bd07c558475824 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_message.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_message.py @@ -51,7 +51,7 @@ def test_query_requires_conversation_id(self): """Test conversation_id is required.""" conversation_id = str(uuid.uuid4()) query = MessageListQuery(conversation_id=conversation_id) - assert str(query.conversation_id) == conversation_id + assert query.conversation_id == conversation_id def test_query_with_defaults(self): """Test query with default values.""" @@ -87,13 +87,13 @@ def test_query_rejects_limit_below_minimum(self): """Test query rejects limit < 1.""" conversation_id = str(uuid.uuid4()) with pytest.raises(ValueError): - MessageListQuery(conversation_id=conversation_id, limit=0) + MessageListQuery(conversation_id=conversation_id, limit=0) # pyrefly: ignore[bad-argument-type] def test_query_rejects_limit_above_maximum(self): """Test query rejects limit > 100.""" conversation_id = str(uuid.uuid4()) with pytest.raises(ValueError): - MessageListQuery(conversation_id=conversation_id, limit=101) + MessageListQuery(conversation_id=conversation_id, limit=101) # pyrefly: ignore[bad-argument-type] class TestMessageFeedbackPayload: @@ -131,6 +131,7 @@ def test_payload_with_long_content(self): """Test payload with long feedback content.""" long_content = "A" * 1000 payload = MessageFeedbackPayload(content=long_content) + assert payload.content is not None assert len(payload.content) == 1000 def test_payload_with_unicode_content(self): @@ -163,7 +164,7 @@ def test_query_page_minimum(self): def test_query_rejects_page_below_minimum(self): """Test query rejects page < 1.""" with pytest.raises(ValueError): - FeedbackListQuery(page=0) + FeedbackListQuery(page=0) # pyrefly: ignore[bad-argument-type] def test_query_limit_boundaries(self): """Test query limit boundaries.""" @@ -176,12 +177,12 @@ def test_query_limit_boundaries(self): def test_query_rejects_limit_below_minimum(self): """Test query rejects limit < 1.""" with pytest.raises(ValueError): - FeedbackListQuery(limit=0) + FeedbackListQuery(limit=0) # pyrefly: ignore[bad-argument-type] def test_query_rejects_limit_above_maximum(self): """Test query rejects limit > 101.""" with pytest.raises(ValueError): - FeedbackListQuery(limit=102) + FeedbackListQuery(limit=102) # pyrefly: ignore[bad-argument-type] class TestMessageAppModeValidation: @@ -449,7 +450,20 @@ def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: class TestAppGetFeedbacksApi: def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(MessageService, "get_all_messages_feedbacks", lambda *_args, **_kwargs: ["f1"]) + feedback = { + "id": "feedback-1", + "app_id": "app-1", + "conversation_id": "conversation-1", + "message_id": "message-1", + "rating": "like", + "content": "helpful answer", + "from_source": "user", + "from_end_user_id": "end-user-1", + "from_account_id": None, + "created_at": "2024-01-02T03:04:05", + "updated_at": "2024-01-02T03:04:06", + } + monkeypatch.setattr(MessageService, "get_all_messages_feedbacks", lambda *_args, **_kwargs: [feedback]) api = AppGetFeedbacksApi() handler = unwrap(api.get) @@ -458,7 +472,7 @@ def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: with app.test_request_context("/app/feedbacks?page=1&limit=20", method="GET"): response = handler(api, app_model=app_model) - assert response == {"data": ["f1"]} + assert response == {"data": [feedback]} class TestMessageSuggestedApi: diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py index 4f88ae69c2d90b..63076dfe81f6a8 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py @@ -13,15 +13,17 @@ - Service method interfaces """ +import json import sys import uuid +from dataclasses import dataclass, field from datetime import UTC, datetime from inspect import unwrap -from types import SimpleNamespace from unittest.mock import Mock, patch import pytest from flask import Flask +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, NotFound from controllers.service_api.app.error import NotWorkflowAppError @@ -35,31 +37,175 @@ WorkflowRunByIdApi, WorkflowRunDetailApi, WorkflowRunPayload, + WorkflowRunResponse, WorkflowTaskStopApi, ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError +from core.app.entities.app_invoke_entities import InvokeFrom from graphon.enums import WorkflowExecutionStatus -from models.model import App, AppMode +from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.model import App, AppMode, EndUser +from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun, WorkflowType from services.app_generate_service import AppGenerateService from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError -from services.workflow_app_service import WorkflowAppService +from services.workflow_app_service import LogView, LogViewDetails, WorkflowAppService + + +def _default_workflow_inputs() -> dict[str, object]: + return {"input": "value"} + + +def _default_log_details() -> LogViewDetails: + return {"trigger_metadata": {"node": "answer", "latency": 1.25}} + + +class _DbSessionStub: + def get(self, *args: object, **kwargs: object) -> None: + return None + + +@dataclass +class _DbStub: + engine: object = field(default_factory=object) + session: _DbSessionStub = field(default_factory=_DbSessionStub) + + +@dataclass +class _WorkflowRunRepositoryStub: + run: WorkflowRun | None + + def get_workflow_run_by_id(self, *, tenant_id: str, app_id: str, run_id: str) -> WorkflowRun | None: + return self.run if tenant_id and app_id and run_id else None + + def get_workflow_run_by_id_without_tenant(self, *, run_id: str) -> WorkflowRun | None: + return self.run if run_id else None + + +class _BeginStub: + def __enter__(self) -> object: + return object() + + def __exit__(self, exc_type: object, exc: object, tb: object) -> bool: + return False + + +class _SessionMakerStub: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def begin(self) -> _BeginStub: + return _BeginStub() + + +def _make_workflow_run( + run_id: str = "run-1", + *, + workflow_id: str = "wf-1", + inputs: dict[str, object] | None = None, + outputs: dict[str, object] | None = None, + created_at: datetime | None = None, + finished_at: datetime | None = None, +) -> WorkflowRun: + return WorkflowRun( + id=run_id, + tenant_id="tenant-1", + app_id="app-1", + workflow_id=workflow_id, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="2026-01-01", + graph=json.dumps({"nodes": [], "edges": []}), + inputs=json.dumps(inputs if inputs is not None else _default_workflow_inputs()), + outputs=json.dumps(outputs if outputs is not None else {"output": "value"}), + status=WorkflowExecutionStatus.SUCCEEDED, + error=None, + elapsed_time=0.1, + total_tokens=10, + total_steps=1, + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + created_at=created_at or datetime(2026, 1, 1, tzinfo=UTC), + finished_at=finished_at or datetime(2026, 1, 1, tzinfo=UTC), + exceptions_count=0, + ) + + +def _make_workflow_app_log() -> WorkflowAppLog: + log = WorkflowAppLog( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="wf-1", + workflow_run_id="log-run-1", + created_from=WorkflowAppLogCreatedFrom.SERVICE_API, + created_by_role=CreatorUserRole.ACCOUNT, + created_by="account-1", + ) + log.id = "app-log-1" + log.created_at = datetime(2026, 1, 1, 1, 0, 3, tzinfo=UTC) + return log + + +def _make_workflow_log_page() -> dict[str, object]: + return { + "page": 1, + "limit": 20, + "total": 1, + "has_more": False, + "data": [LogView(_make_workflow_app_log(), _default_log_details())], + } + + +def _make_app_model( + *, + app_id: str = "app-1", + tenant_id: str = "tenant-1", + mode: AppMode = AppMode.WORKFLOW, +) -> App: + app = App() + app.id = app_id + app.tenant_id = tenant_id + app.mode = mode + return app -def _make_mock_workflow_run(run_id: str = "run-1"): - run = Mock() - run.id = run_id - run.workflow_id = "wf-1" - run.status = WorkflowExecutionStatus.SUCCEEDED - run.inputs = {"input": "value"} - run.outputs_dict = {"output": "value"} - run.error = None - run.total_steps = 1 - run.total_tokens = 10 - run.created_at = datetime(2026, 1, 1, tzinfo=UTC) - run.finished_at = datetime(2026, 1, 1, tzinfo=UTC) - run.elapsed_time = 0.1 - return run +def _make_end_user(user_id: str = "end-user-1") -> EndUser: + end_user = EndUser() + end_user.id = user_id + return end_user + + +def _expected_workflow_log_pagination_payload() -> dict[str, object]: + return { + "page": 1, + "limit": 20, + "total": 1, + "has_more": False, + "data": [ + { + "id": "app-log-1", + "workflow_run": { + "id": "log-run-1", + "version": "2026-01-01", + "status": "succeeded", + "triggered_from": "app-run", + "error": None, + "elapsed_time": 0.1, + "total_tokens": 10, + "total_steps": 1, + "created_at": 1767229200, + "finished_at": 1767229202, + "exceptions_count": 0, + }, + "details": {"trigger_metadata": {"node": "answer", "latency": 1.25}}, + "created_from": "service-api", + "created_by_role": "account", + "created_by_account": None, + "created_by_end_user": None, + "created_at": 1767229203, + } + ], + } class TestWorkflowRunPayload: @@ -108,6 +254,7 @@ def test_payload_with_multiple_files(self): {"type": "audio", "url": "http://example.com/audio.mp3"}, ] payload = WorkflowRunPayload(inputs={}, files=files) + assert payload.files is not None assert len(payload.files) == 3 @@ -170,22 +317,22 @@ def test_query_pagination_limits(self): def test_query_rejects_page_below_minimum(self): """Test query rejects page < 1.""" with pytest.raises(ValueError): - WorkflowLogQuery(page=0) + WorkflowLogQuery.model_validate({"page": 0}) def test_query_rejects_page_above_maximum(self): """Test query rejects page > 99999.""" with pytest.raises(ValueError): - WorkflowLogQuery(page=100000) + WorkflowLogQuery.model_validate({"page": 100000}) def test_query_rejects_limit_below_minimum(self): """Test query rejects limit < 1.""" with pytest.raises(ValueError): - WorkflowLogQuery(limit=0) + WorkflowLogQuery.model_validate({"limit": 0}) def test_query_rejects_limit_above_maximum(self): """Test query rejects limit > 100.""" with pytest.raises(ValueError): - WorkflowLogQuery(limit=101) + WorkflowLogQuery.model_validate({"limit": 101}) def test_query_with_keyword_search(self): """Test query with keyword filter.""" @@ -199,6 +346,29 @@ def test_query_with_date_filters(self): assert query.created_at__after == "2024-01-01T00:00:00Z" +class TestWorkflowRunResponse: + def test_validates_workflow_run_object_shape_and_clears_paused_outputs(self): + run = _make_workflow_run(run_id="run-paused") + run.status = WorkflowExecutionStatus.PAUSED + run.outputs = json.dumps({"should": "not leak"}) + + result = WorkflowRunResponse.model_validate(run, from_attributes=True).model_dump(mode="json") + + assert result == { + "id": "run-paused", + "workflow_id": "wf-1", + "status": "paused", + "inputs": '{"input": "value"}', + "outputs": {}, + "error": None, + "total_steps": 1, + "total_tokens": 10, + "created_at": 1767225600, + "finished_at": 1767225600, + "elapsed_time": 0.1, + } + + class TestWorkflowAppService: """Test WorkflowAppService interface.""" @@ -215,17 +385,13 @@ def test_get_paginate_workflow_app_logs_method_exists(self): @patch.object(WorkflowAppService, "get_paginate_workflow_app_logs") def test_get_paginate_workflow_app_logs_returns_pagination(self, mock_get_logs): """Test get_paginate_workflow_app_logs returns paginated result.""" - mock_pagination = Mock() - mock_pagination.data = [] - mock_pagination.page = 1 - mock_pagination.limit = 20 - mock_pagination.total = 0 - mock_get_logs.return_value = mock_pagination + pagination = _make_workflow_log_page() + mock_get_logs.return_value = pagination service = WorkflowAppService() result = service.get_paginate_workflow_app_logs( session=Mock(), - app_model=Mock(spec=App), + app_model=_make_app_model(), keyword=None, status=None, created_at_before=None, @@ -236,8 +402,7 @@ def test_get_paginate_workflow_app_logs_returns_pagination(self, mock_get_logs): created_by_account=None, ) - assert result.page == 1 - assert result.limit == 20 + assert result == pagination class TestWorkflowExecutionStatus: @@ -268,10 +433,10 @@ def test_generate_accepts_workflow_args(self, mock_generate): mock_generate.return_value = {"result": "success"} result = AppGenerateService.generate( - app_model=Mock(spec=App), - user=Mock(), + app_model=_make_app_model(), + user=_make_end_user(), args={"inputs": {"key": "value"}, "workflow_id": "workflow_123"}, - invoke_from=Mock(), + invoke_from=InvokeFrom.SERVICE_API, streaming=False, ) @@ -285,10 +450,10 @@ def test_generate_raises_workflow_not_found_error(self, mock_generate): with pytest.raises(WorkflowNotFoundError): AppGenerateService.generate( - app_model=Mock(spec=App), - user=Mock(), + app_model=_make_app_model(), + user=_make_end_user(), args={"workflow_id": "invalid_id"}, - invoke_from=Mock(), + invoke_from=InvokeFrom.SERVICE_API, streaming=False, ) @@ -299,10 +464,10 @@ def test_generate_raises_is_draft_workflow_error(self, mock_generate): with pytest.raises(IsDraftWorkflowError): AppGenerateService.generate( - app_model=Mock(spec=App), - user=Mock(), + app_model=_make_app_model(), + user=_make_end_user(), args={"workflow_id": "draft_workflow"}, - invoke_from=Mock(), + invoke_from=InvokeFrom.SERVICE_API, streaming=False, ) @@ -313,10 +478,10 @@ def test_generate_supports_streaming_mode(self, mock_generate): mock_generate.return_value = mock_stream result = AppGenerateService.generate( - app_model=Mock(spec=App), - user=Mock(), + app_model=_make_app_model(), + user=_make_end_user(), args={"inputs": {}, "response_mode": "streaming"}, - invoke_from=Mock(), + invoke_from=InvokeFrom.SERVICE_API, streaming=True, ) @@ -351,37 +516,33 @@ def test_repository_factory_can_create_workflow_run_repository(self): @patch("repositories.factory.DifyAPIRepositoryFactory.create_api_workflow_run_repository") def test_workflow_run_repository_get_by_id(self, mock_factory): """Test workflow run repository get_workflow_run_by_id method.""" - mock_repo = Mock() - mock_run = Mock() - mock_run.id = str(uuid.uuid4()) - mock_run.status = "succeeded" - mock_repo.get_workflow_run_by_id.return_value = mock_run - mock_factory.return_value = mock_repo + run = _make_workflow_run(run_id=str(uuid.uuid4())) + mock_factory.return_value = _WorkflowRunRepositoryStub(run=run) from repositories.factory import DifyAPIRepositoryFactory - repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(Mock()) + repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(sessionmaker()) result = repo.get_workflow_run_by_id(tenant_id="tenant_123", app_id="app_456", run_id="run_789") - assert result.status == "succeeded" + assert result == run class TestWorkflowRunDetailApi: def test_not_workflow_app(self, app: Flask) -> None: api = WorkflowRunDetailApi() handler = unwrap(api.get) - app_model = SimpleNamespace(mode=AppMode.CHAT.value) + app_model = _make_app_model(mode=AppMode.CHAT) with app.test_request_context("/workflows/run/1", method="GET"): with pytest.raises(NotWorkflowAppError): handler(api, app_model=app_model, workflow_run_id="run") def test_success(self, monkeypatch: pytest.MonkeyPatch) -> None: - run = _make_mock_workflow_run(run_id="run") - repo = SimpleNamespace(get_workflow_run_by_id=lambda **_kwargs: run) + run = _make_workflow_run(run_id="run") + repo = _WorkflowRunRepositoryStub(run=run) workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(workflow_module, "db", _DbStub()) monkeypatch.setattr( DifyAPIRepositoryFactory, "create_api_workflow_run_repository", @@ -390,7 +551,7 @@ def test_success(self, monkeypatch: pytest.MonkeyPatch) -> None: api = WorkflowRunDetailApi() handler = unwrap(api.get) - app_model = SimpleNamespace(mode=AppMode.WORKFLOW, tenant_id="t1", id="a1") + app_model = _make_app_model(app_id="a1", tenant_id="t1") result = handler(api, app_model=app_model, workflow_run_id="run") assert result["id"] == "run" @@ -402,8 +563,8 @@ class TestWorkflowRunApi: def test_not_workflow_app(self, app: Flask) -> None: api = WorkflowRunApi() handler = unwrap(api.post) - app_model = SimpleNamespace(mode=AppMode.CHAT.value) - end_user = SimpleNamespace() + app_model = _make_app_model(mode=AppMode.CHAT) + end_user = _make_end_user() with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}): with pytest.raises(NotWorkflowAppError): @@ -418,8 +579,8 @@ def test_rate_limit(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = WorkflowRunApi() handler = unwrap(api.post) - app_model = SimpleNamespace(mode=AppMode.WORKFLOW) - end_user = SimpleNamespace() + app_model = _make_app_model() + end_user = _make_end_user() with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}): with pytest.raises(InvokeRateLimitHttpError): @@ -436,8 +597,8 @@ def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = WorkflowRunByIdApi() handler = unwrap(api.post) - app_model = SimpleNamespace(mode=AppMode.WORKFLOW) - end_user = SimpleNamespace() + app_model = _make_app_model() + end_user = _make_end_user() with app.test_request_context("/workflows/1/run", method="POST", json={"inputs": {}}): with pytest.raises(NotFound): @@ -452,8 +613,8 @@ def test_draft_workflow(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> No api = WorkflowRunByIdApi() handler = unwrap(api.post) - app_model = SimpleNamespace(mode=AppMode.WORKFLOW) - end_user = SimpleNamespace() + app_model = _make_app_model() + end_user = _make_end_user() with app.test_request_context("/workflows/1/run", method="POST", json={"inputs": {}}): with pytest.raises(BadRequest): @@ -464,8 +625,8 @@ class TestWorkflowTaskStopApi: def test_wrong_mode(self, app: Flask) -> None: api = WorkflowTaskStopApi() handler = unwrap(api.post) - app_model = SimpleNamespace(mode=AppMode.CHAT.value) - end_user = SimpleNamespace() + app_model = _make_app_model(mode=AppMode.CHAT) + end_user = _make_end_user() with app.test_request_context("/workflows/tasks/1/stop", method="POST"): with pytest.raises(NotWorkflowAppError): @@ -479,8 +640,8 @@ def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = WorkflowTaskStopApi() handler = unwrap(api.post) - app_model = SimpleNamespace(mode=AppMode.WORKFLOW) - end_user = SimpleNamespace(id="u1") + app_model = _make_app_model() + end_user = _make_end_user(user_id="u1") with app.test_request_context("/workflows/tasks/1/stop", method="POST"): response = handler(api, app_model=app_model, end_user=end_user, task_id="t1") @@ -492,37 +653,36 @@ def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: class TestWorkflowAppLogApi: def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - class _BeginStub: - def __enter__(self): - return SimpleNamespace() - - def __exit__(self, exc_type, exc, tb): - return False - - class _SessionMakerStub: - def __init__(self, *args, **kwargs): - pass - - def begin(self): - return _BeginStub() - workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + workflow_model_module = sys.modules["models.workflow"] + monkeypatch.setattr(workflow_module, "db", _DbStub()) + monkeypatch.setattr(workflow_model_module, "db", _DbStub()) monkeypatch.setattr(workflow_module, "sessionmaker", _SessionMakerStub) monkeypatch.setattr( WorkflowAppService, "get_paginate_workflow_app_logs", - lambda *_args, **_kwargs: {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}, + lambda *_args, **_kwargs: _make_workflow_log_page(), + ) + monkeypatch.setattr( + DifyAPIRepositoryFactory, + "create_api_workflow_run_repository", + lambda *_args, **_kwargs: _WorkflowRunRepositoryStub( + run=_make_workflow_run( + run_id="log-run-1", + created_at=datetime(2026, 1, 1, 1, tzinfo=UTC), + finished_at=datetime(2026, 1, 1, 1, 0, 2, tzinfo=UTC), + ) + ), ) api = WorkflowAppLogApi() handler = unwrap(api.get) - app_model = SimpleNamespace(id="a1") + app_model = _make_app_model(app_id="a1") with app.test_request_context("/workflows/logs", method="GET"): response = handler(api, app_model=app_model) - assert response == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []} + assert response == _expected_workflow_log_pagination_payload() # ============================================================================= @@ -536,12 +696,8 @@ def begin(self): @pytest.fixture -def mock_workflow_app(): - app = Mock(spec=App) - app.id = str(uuid.uuid4()) - app.tenant_id = str(uuid.uuid4()) - app.mode = AppMode.WORKFLOW - return app +def workflow_app() -> App: + return _make_app_model(app_id=str(uuid.uuid4()), tenant_id=str(uuid.uuid4())) class TestWorkflowRunDetailApiGet: @@ -558,38 +714,46 @@ def test_get_workflow_run_success( mock_db, mock_repo_factory, app: Flask, - mock_workflow_app, + workflow_app: App, ): """Test successful workflow run detail retrieval.""" - mock_run = _make_mock_workflow_run(run_id="run-1") - mock_repo = Mock() - mock_repo.get_workflow_run_by_id.return_value = mock_run - mock_repo_factory.create_api_workflow_run_repository.return_value = mock_repo + run = _make_workflow_run(run_id="run-1") + mock_repo_factory.create_api_workflow_run_repository.return_value = _WorkflowRunRepositoryStub(run=run) from controllers.service_api.app.workflow import WorkflowRunDetailApi with app.test_request_context( - f"/workflows/run/{mock_run.id}", + f"/workflows/run/{run.id}", method="GET", ): api = WorkflowRunDetailApi() - result = unwrap(api.get)(api, app_model=mock_workflow_app, workflow_run_id=mock_run.id) - - assert result["id"] == mock_run.id - assert result["status"] == "succeeded" + result = unwrap(api.get)(api, app_model=workflow_app, workflow_run_id=run.id) + + assert result == { + "id": "run-1", + "workflow_id": "wf-1", + "status": "succeeded", + "inputs": '{"input": "value"}', + "outputs": {"output": "value"}, + "error": None, + "total_steps": 1, + "total_tokens": 10, + "created_at": 1767225600, + "finished_at": 1767225600, + "elapsed_time": 0.1, + } @patch("controllers.service_api.app.workflow.db") def test_get_workflow_run_wrong_app_mode(self, mock_db, app: Flask): """Test NotWorkflowAppError when app mode is not workflow or advanced_chat.""" from controllers.service_api.app.workflow import WorkflowRunDetailApi - mock_app = Mock(spec=App) - mock_app.mode = AppMode.CHAT.value + app_model = _make_app_model(mode=AppMode.CHAT) with app.test_request_context("/workflows/run/run-1", method="GET"): api = WorkflowRunDetailApi() with pytest.raises(NotWorkflowAppError): - unwrap(api.get)(api, app_model=mock_app, workflow_run_id="run-1") + unwrap(api.get)(api, app_model=app_model, workflow_run_id="run-1") class TestWorkflowTaskStopApiPost: @@ -605,7 +769,7 @@ def test_stop_workflow_task_success( mock_queue_mgr, mock_graph_mgr, app: Flask, - mock_workflow_app, + workflow_app: App, ): """Test successful workflow task stop.""" from controllers.service_api.app.workflow import WorkflowTaskStopApi @@ -614,8 +778,8 @@ def test_stop_workflow_task_success( api = WorkflowTaskStopApi() result = unwrap(api.post)( api, - app_model=mock_workflow_app, - end_user=Mock(), + app_model=workflow_app, + end_user=_make_end_user(), task_id="task-1", ) @@ -628,13 +792,12 @@ def test_stop_workflow_task_wrong_app_mode(self, app: Flask): """Test NotWorkflowAppError when app mode is not workflow.""" from controllers.service_api.app.workflow import WorkflowTaskStopApi - mock_app = Mock(spec=App) - mock_app.mode = AppMode.COMPLETION.value + app_model = _make_app_model(mode=AppMode.COMPLETION) with app.test_request_context("/workflows/tasks/task-1/stop", method="POST"): api = WorkflowTaskStopApi() with pytest.raises(NotWorkflowAppError): - unwrap(api.post)(api, app_model=mock_app, end_user=Mock(), task_id="task-1") + unwrap(api.post)(api, app_model=app_model, end_user=_make_end_user(), task_id="task-1") class TestWorkflowAppLogApiGet: @@ -650,27 +813,23 @@ def test_get_workflow_logs_success( mock_db, mock_wf_svc_cls, app: Flask, - mock_workflow_app, + workflow_app: App, ): """Test successful workflow log retrieval.""" - mock_pagination = Mock() - mock_pagination.page = 1 - mock_pagination.limit = 20 - mock_pagination.total = 0 - mock_pagination.has_more = False - mock_pagination.data = [] mock_svc_instance = Mock() - mock_svc_instance.get_paginate_workflow_app_logs.return_value = mock_pagination + mock_svc_instance.get_paginate_workflow_app_logs.return_value = _make_workflow_log_page() mock_wf_svc_cls.return_value = mock_svc_instance + mock_repo = _WorkflowRunRepositoryStub( + run=_make_workflow_run( + run_id="log-run-1", + created_at=datetime(2026, 1, 1, 1, tzinfo=UTC), + finished_at=datetime(2026, 1, 1, 1, 0, 2, tzinfo=UTC), + ) + ) # Mock sessionmaker(...).begin() context manager - mock_session = Mock() - mock_db.engine = Mock() - mock_begin = Mock() - mock_begin.__enter__ = Mock(return_value=mock_session) - mock_begin.__exit__ = Mock(return_value=False) - mock_session_factory = Mock() - mock_session_factory.begin.return_value = mock_begin + mock_db.engine = object() + mock_db.session.get.return_value = None from controllers.service_api.app.workflow import WorkflowAppLogApi @@ -678,8 +837,15 @@ def test_get_workflow_logs_success( "/workflows/logs?page=1&limit=20", method="GET", ): - with patch("controllers.service_api.app.workflow.sessionmaker", return_value=mock_session_factory): + with ( + patch("controllers.service_api.app.workflow.sessionmaker", _SessionMakerStub), + patch("models.workflow.db", _DbStub()), + patch( + "repositories.factory.DifyAPIRepositoryFactory.create_api_workflow_run_repository", + return_value=mock_repo, + ), + ): api = WorkflowAppLogApi() - result = unwrap(api.get)(api, app_model=mock_workflow_app) + result = unwrap(api.get)(api, app_model=workflow_app) - assert result == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []} + assert result == _expected_workflow_log_pagination_payload() diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow_fields.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow_fields.py index eda270258d5470..caa6ddc0d93ca6 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow_fields.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow_fields.py @@ -1,25 +1,36 @@ -from types import SimpleNamespace - -from controllers.service_api.app.workflow import WorkflowRunOutputsField, WorkflowRunStatusField +from controllers.service_api.app.workflow import WorkflowRunResponse from graphon.enums import WorkflowExecutionStatus +from libs.helper import dump_response +from models.workflow import WorkflowRun + + +def _workflow_run(status: WorkflowExecutionStatus, outputs: str | None = '{"foo": "bar"}') -> WorkflowRun: + return WorkflowRun( + id="run-id", + workflow_id="workflow-id", + status=status, + inputs="{}", + outputs=outputs, + error=None, + total_steps=1, + total_tokens=2, + elapsed_time=3.5, + ) -def test_workflow_run_status_field_with_enum() -> None: - field = WorkflowRunStatusField() - obj = SimpleNamespace(status=WorkflowExecutionStatus.PAUSED) +def test_workflow_run_serializer_normalizes_status_enum() -> None: + response = dump_response(WorkflowRunResponse, _workflow_run(WorkflowExecutionStatus.PAUSED)) - assert field.output("status", obj) == "paused" + assert response["status"] == "paused" -def test_workflow_run_outputs_field_paused_returns_empty() -> None: - field = WorkflowRunOutputsField() - obj = SimpleNamespace(status=WorkflowExecutionStatus.PAUSED, outputs_dict={"foo": "bar"}) +def test_workflow_run_serializer_paused_returns_empty_outputs() -> None: + response = dump_response(WorkflowRunResponse, _workflow_run(WorkflowExecutionStatus.PAUSED)) - assert field.output("outputs", obj) == {} + assert response["outputs"] == {} -def test_workflow_run_outputs_field_running_returns_outputs() -> None: - field = WorkflowRunOutputsField() - obj = SimpleNamespace(status=WorkflowExecutionStatus.RUNNING, outputs_dict={"foo": "bar"}) +def test_workflow_run_serializer_running_returns_outputs() -> None: + response = dump_response(WorkflowRunResponse, _workflow_run(WorkflowExecutionStatus.RUNNING)) - assert field.output("outputs", obj) == {"foo": "bar"} + assert response["outputs"] == {"foo": "bar"} diff --git a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py index 362af883ed23f8..43cc2450db5475 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py @@ -325,10 +325,12 @@ def test_entity_blocking_response_mode(self): def test_entity_missing_required_field(self): """Test entity raises on missing required field.""" with pytest.raises(ValueError): - PipelineRunApiEntity( - inputs={}, - datasource_type="online_document", - # missing datasource_info_list, start_node_id, etc. + PipelineRunApiEntity.model_validate( + { + "inputs": {}, + "datasource_type": "online_document", + # missing datasource_info_list, start_node_id, etc. + } ) @@ -382,8 +384,19 @@ def test_get_plugins_success(self, mock_svc_cls, mock_db, app: Flask): mock_dataset = Mock() mock_db.session.scalar.return_value = mock_dataset + datasource_plugins = [ + { + "node_id": "node-datasource-1", + "plugin_id": "plugin-a", + "provider_name": "provider-a", + "datasource_type": "online_document", + "title": "Online Docs", + "user_input_variables": [{"variable": "url", "label": "URL", "type": "text-input", "required": True}], + "credentials": [{"id": "cred-1", "name": "Default credential", "type": "oauth2", "is_default": True}], + } + ] mock_svc_instance = Mock() - mock_svc_instance.get_datasource_plugins.return_value = [{"name": "plugin_a"}] + mock_svc_instance.get_datasource_plugins.return_value = datasource_plugins mock_svc_cls.return_value = mock_svc_instance with app.test_request_context("/datasets/test/pipeline/datasource-plugins?is_published=true"): @@ -391,11 +404,33 @@ def test_get_plugins_success(self, mock_svc_cls, mock_db, app: Flask): response, status = api.get(tenant_id=tenant_id, dataset_id=dataset_id) assert status == 200 - assert response == [{"name": "plugin_a"}] + assert response == datasource_plugins mock_svc_instance.get_datasource_plugins.assert_called_once_with( tenant_id=tenant_id, dataset_id=dataset_id, is_published=True ) + @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") + @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService") + def test_get_plugins_parses_false_is_published_query(self, mock_svc_cls, mock_db, app: Flask): + """Test false query string is parsed as boolean False.""" + tenant_id = str(uuid.uuid4()) + dataset_id = str(uuid.uuid4()) + + mock_db.session.scalar.return_value = Mock() + mock_svc_instance = Mock() + mock_svc_instance.get_datasource_plugins.return_value = [] + mock_svc_cls.return_value = mock_svc_instance + + with app.test_request_context("/datasets/test/pipeline/datasource-plugins?is_published=false"): + api = DatasourcePluginsApi() + response, status = api.get(tenant_id=tenant_id, dataset_id=dataset_id) + + assert status == 200 + assert response == [] + mock_svc_instance.get_datasource_plugins.assert_called_once_with( + tenant_id=tenant_id, dataset_id=dataset_id, is_published=False + ) + @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") def test_get_plugins_not_found(self, mock_db, app: Flask): """Test NotFound when dataset check fails.""" diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py index 16b54acd8c652c..58d534ea27c495 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py @@ -15,7 +15,10 @@ - API endpoint business logic and error handling """ +import json import uuid +from dataclasses import dataclass, field +from datetime import UTC, datetime from unittest.mock import Mock, patch import pytest @@ -39,46 +42,151 @@ ) from controllers.service_api.dataset.error import ArchivedDocumentImmutableError from core.rag.index_processor.constant.index_type import IndexStructureType -from models.enums import IndexingStatus +from models.dataset import Dataset, Document +from models.enums import DataSourceType, DocumentCreatedFrom, DocumentDocType, IndexingStatus from services.dataset_service import DocumentService from services.entities.knowledge_entities.knowledge_entities import ProcessRule, RetrievalModel -def make_serializable_document(**overrides: object) -> Mock: - attrs: dict[str, object] = { - "id": str(uuid.uuid4()), - "position": 1, - "data_source_type": "upload_file", - "data_source_info_dict": {"upload_file_id": "file-1"}, - "data_source_detail_dict": {}, - "dataset_process_rule_id": None, - "batch": "batch-1", - "name": "Test Document", - "created_from": "api", - "created_by": "user-1", - "created_at": None, - "tokens": None, - "indexing_status": "completed", - "error": None, - "enabled": True, - "disabled_at": None, - "disabled_by": None, - "archived": False, - "display_status": "available", - "word_count": None, - "hit_count": 0, - "doc_form": "text_model", - "doc_metadata_details": None, - "summary_index_status": None, - "need_summary": False, - } - attrs.update(overrides) - document = Mock(spec_set=list(attrs)) - for name, value in attrs.items(): +def _document_data_source_info() -> dict[str, str]: + return {"type": "website_crawl", "url": "https://example.com/docs", "title": "Docs"} + + +@dataclass +class _DocumentModelSessionStub: + scalar_values: list[object] = field(default_factory=list) + + def scalar(self, *args: object, **kwargs: object) -> object: + if self.scalar_values: + return self.scalar_values.pop(0) + return None + + def scalars(self, *args: object, **kwargs: object) -> object: + result = Mock() + result.all.return_value = [] + return result + + def get(self, *args: object, **kwargs: object) -> None: + return None + + +@dataclass +class _DocumentModelDbStub: + session: _DocumentModelSessionStub = field(default_factory=_DocumentModelSessionStub) + + +@dataclass +class _PaginationRecord: + items: list[Document] + total: int + + +@pytest.fixture +def mock_tenant() -> str: + return str(uuid.uuid4()) + + +@pytest.fixture +def mock_dataset(mock_tenant: str) -> Dataset: + return make_dataset(tenant_id=mock_tenant) + + +@pytest.fixture +def mock_document(mock_dataset: Dataset) -> Document: + return make_serializable_document() + + +def make_dataset(**overrides: object) -> Dataset: + dataset = Dataset( + id=str(uuid.uuid4()), + tenant_id=str(uuid.uuid4()), + name="Test Dataset", + data_source_type=DataSourceType.WEBSITE_CRAWL, + indexing_technique="economy", + created_by="user-1", + maintainer="user-1", + provider="vendor", + summary_index_setting=None, + ) + for name, value in overrides.items(): + setattr(dataset, name, value) + return dataset + + +def make_serializable_document(**overrides: object) -> Document: + data_source_info = overrides.pop("data_source_info", _document_data_source_info()) + document = Document( + id=str(uuid.uuid4()), + tenant_id=str(uuid.uuid4()), + dataset_id=str(uuid.uuid4()), + position=1, + data_source_type=DataSourceType.WEBSITE_CRAWL, + data_source_info=json.dumps(data_source_info), + dataset_process_rule_id=None, + batch="batch-1", + name="Test Document", + created_from=DocumentCreatedFrom.API, + created_by="user-1", + created_at=datetime(2021, 1, 1, tzinfo=UTC), + tokens=100, + indexing_status=IndexingStatus.COMPLETED, + completed_at=datetime(2021, 1, 1, 0, 0, 1, tzinfo=UTC), + updated_at=datetime(2021, 1, 1, 0, 0, 2, tzinfo=UTC), + indexing_latency=0.5, + error=None, + enabled=True, + disabled_at=None, + disabled_by=None, + archived=False, + doc_type=DocumentDocType.BOOK, + doc_metadata=None, + doc_form=IndexStructureType.PARAGRAPH_INDEX, + doc_language="English", + need_summary=False, + is_paused=False, + processing_started_at=datetime(2021, 1, 1, tzinfo=UTC), + parsing_completed_at=datetime(2021, 1, 1, 0, 0, 1, tzinfo=UTC), + cleaning_completed_at=datetime(2021, 1, 1, 0, 0, 2, tzinfo=UTC), + splitting_completed_at=datetime(2021, 1, 1, 0, 0, 3, tzinfo=UTC), + paused_at=None, + stopped_at=None, + word_count=None, + ) + document.summary_index_status = None # type: ignore[attr-defined] + for name, value in overrides.items(): setattr(document, name, value) return document +def _expected_document_response(document: Document) -> dict[str, object]: + return { + "id": document.id, + "position": document.position, + "data_source_type": document.data_source_type, + "data_source_info": document.data_source_info_dict, + "data_source_detail_dict": document.data_source_detail_dict, + "dataset_process_rule_id": document.dataset_process_rule_id, + "name": document.name, + "created_from": document.created_from, + "created_by": document.created_by, + "created_at": int(document.created_at.timestamp()) if document.created_at else None, + "tokens": document.tokens, + "indexing_status": document.indexing_status, + "error": document.error, + "enabled": document.enabled, + "disabled_at": int(document.disabled_at.timestamp()) if document.disabled_at else None, + "disabled_by": document.disabled_by, + "archived": document.archived, + "display_status": document.display_status, + "word_count": document.word_count, + "hit_count": 0, + "doc_form": document.doc_form, + "doc_metadata": [], + "summary_index_status": getattr(document, "summary_index_status", None), + "need_summary": document.need_summary, + } + + class TestDocumentTextCreatePayload: """Test suite for DocumentTextCreatePayload Pydantic model.""" @@ -263,23 +371,21 @@ def test_batch_update_document_status_method_exists(self): @patch.object(DocumentService, "get_document") def test_get_document_returns_document(self, mock_get: Mock) -> None: """Test get_document returns document object.""" - mock_doc = Mock() - mock_doc.id = str(uuid.uuid4()) - mock_doc.name = "Test Document" - mock_doc.indexing_status = "completed" - mock_get.return_value = mock_doc + document = make_serializable_document(name="Test Document", indexing_status="completed") + mock_get.return_value = document result = DocumentService.get_document(dataset_id="dataset_id", document_id="doc_id") assert result is not None + assert result == document assert result.name == "Test Document" assert result.indexing_status == "completed" @patch.object(DocumentService, "delete_document") def test_delete_document_called(self, mock_delete): """Test delete_document is called with document.""" - mock_doc = Mock() - DocumentService.delete_document(document=mock_doc) - mock_delete.assert_called_once_with(document=mock_doc) + document = make_serializable_document() + DocumentService.delete_document(document=document) + mock_delete.assert_called_once_with(document=document) class TestDocumentIndexingStatus: @@ -531,9 +637,9 @@ class TestStopError(Exception): # These tests call controller methods directly, bypassing the # ``DatasetApiResource.method_decorators`` (``validate_dataset_token``) by # invoking the *undecorated* method on the class instance. Every external -# dependency (``db``, service classes, ``marshal``, ``current_user``, …) is -# patched at the module where it is looked up so the real SQLAlchemy / Flask -# extensions are never touched. +# dependency (``db``, service classes, ``current_user``, …) is patched at the +# module where it is looked up so the real SQLAlchemy / Flask extensions are +# never touched. # ============================================================================= @@ -546,58 +652,33 @@ class TestDocumentApiGet: """ @pytest.fixture - def mock_doc_detail(self, mock_tenant: Mock) -> Mock: - """A document mock with every attribute ``DocumentApi.get`` reads.""" - doc = Mock() - doc.id = str(uuid.uuid4()) - doc.tenant_id = mock_tenant.id - doc.name = "test_document.txt" - doc.indexing_status = "completed" - doc.enabled = True - doc.doc_form = IndexStructureType.PARAGRAPH_INDEX - doc.doc_language = "English" - doc.doc_type = "book" - doc.doc_metadata_details = {"source": "upload"} - doc.position = 1 - doc.data_source_type = "upload_file" - doc.data_source_detail_dict = {"type": "upload_file"} - doc.dataset_process_rule_id = str(uuid.uuid4()) - doc.dataset_process_rule = None - doc.created_from = "api" - doc.created_by = str(uuid.uuid4()) - doc.created_at = Mock() - doc.created_at.timestamp.return_value = 1609459200 - doc.tokens = 100 - doc.completed_at = Mock() - doc.completed_at.timestamp.return_value = 1609459200 - doc.updated_at = Mock() - doc.updated_at.timestamp.return_value = 1609459200 - doc.indexing_latency = 0.5 - doc.error = None - doc.disabled_at = None - doc.disabled_by = None - doc.archived = False - doc.segment_count = 5 - doc.average_segment_length = 20 - doc.hit_count = 0 - doc.display_status = "available" - doc.need_summary = False - return doc + def mock_doc_detail(self, mock_tenant: str) -> Document: + """A concrete document record with every attribute ``DocumentApi.get`` reads.""" + return make_serializable_document( + id=str(uuid.uuid4()), + tenant_id=mock_tenant, + name="test_document.txt", + dataset_process_rule_id=str(uuid.uuid4()), + word_count=100, + ) @patch("controllers.service_api.dataset.document.DatasetService") @patch("controllers.service_api.dataset.document.DocumentService") def test_get_document_success_with_all_metadata( - self, mock_doc_svc: Mock, mock_dataset_svc: Mock, app: Flask, mock_tenant: Mock, mock_doc_detail: Mock + self, + mock_doc_svc: Mock, + mock_dataset_svc: Mock, + app: Flask, + mock_tenant: str, + mock_doc_detail: Document, ) -> None: """Test successful document retrieval with metadata='all'.""" # Arrange dataset_id = str(uuid.uuid4()) - mock_dataset = Mock() - mock_dataset.id = dataset_id - mock_dataset.summary_index_setting = None + mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant, summary_index_setting=None) mock_doc_svc.get_document.return_value = mock_doc_detail - mock_dataset_svc.get_process_rules.return_value = [] + mock_dataset_svc.get_process_rules.return_value = {"mode": "automatic", "rules": {}} # Act with app.test_request_context( @@ -605,23 +686,54 @@ def test_get_document_success_with_all_metadata( method="GET", ): api = DocumentApi() - with patch.object(api, "get_dataset", return_value=mock_dataset): - response = api.get(tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id=mock_doc_detail.id) + with ( + patch.object(api, "get_dataset", return_value=mock_dataset), + patch("models.dataset.db", _DocumentModelDbStub(_DocumentModelSessionStub([5, 5, 5, 5, 0]))), + ): + response = api.get(tenant_id=mock_tenant, dataset_id=dataset_id, document_id=mock_doc_detail.id) # Assert - assert response["id"] == mock_doc_detail.id - assert response["name"] == mock_doc_detail.name - assert response["indexing_status"] == mock_doc_detail.indexing_status - assert "doc_type" in response - assert "doc_metadata" in response + assert response == { + "id": mock_doc_detail.id, + "position": 1, + "data_source_type": "website_crawl", + "data_source_info": {"type": "website_crawl", "url": "https://example.com/docs", "title": "Docs"}, + "dataset_process_rule_id": mock_doc_detail.dataset_process_rule_id, + "dataset_process_rule": {"mode": "automatic", "rules": {}}, + "document_process_rule": {}, + "name": "test_document.txt", + "created_from": "api", + "created_by": "user-1", + "created_at": 1609459200, + "tokens": 100, + "indexing_status": "completed", + "completed_at": 1609459201, + "updated_at": 1609459202, + "indexing_latency": 0.5, + "error": None, + "enabled": True, + "disabled_at": None, + "disabled_by": None, + "archived": False, + "doc_type": "book", + "doc_metadata": None, + "segment_count": 5, + "average_segment_length": 20, + "hit_count": 0, + "display_status": "available", + "doc_form": "text_model", + "doc_language": "English", + "summary_index_status": None, + "need_summary": False, + } + assert response["summary_index_status"] is None @patch("controllers.service_api.dataset.document.DocumentService") - def test_get_document_not_found(self, mock_doc_svc: Mock, app: Flask, mock_tenant: Mock) -> None: + def test_get_document_not_found(self, mock_doc_svc: Mock, app: Flask, mock_tenant: str) -> None: """Test 404 when document is not found.""" # Arrange dataset_id = str(uuid.uuid4()) - mock_dataset = Mock() - mock_dataset.id = dataset_id + mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant) mock_doc_svc.get_document.return_value = None @@ -633,17 +745,16 @@ def test_get_document_not_found(self, mock_doc_svc: Mock, app: Flask, mock_tenan api = DocumentApi() with patch.object(api, "get_dataset", return_value=mock_dataset): with pytest.raises(NotFound): - api.get(tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id="nonexistent") + api.get(tenant_id=mock_tenant, dataset_id=dataset_id, document_id="nonexistent") @patch("controllers.service_api.dataset.document.DocumentService") def test_get_document_forbidden_wrong_tenant( - self, mock_doc_svc: Mock, app: Flask, mock_tenant: Mock, mock_doc_detail: Mock + self, mock_doc_svc: Mock, app: Flask, mock_tenant: str, mock_doc_detail: Document ) -> None: """Test 403 when document tenant doesn't match request tenant.""" # Arrange dataset_id = str(uuid.uuid4()) - mock_dataset = Mock() - mock_dataset.id = dataset_id + mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant) mock_doc_detail.tenant_id = "different-tenant-id" mock_doc_svc.get_document.return_value = mock_doc_detail @@ -656,18 +767,16 @@ def test_get_document_forbidden_wrong_tenant( api = DocumentApi() with patch.object(api, "get_dataset", return_value=mock_dataset): with pytest.raises(Forbidden): - api.get(tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id=mock_doc_detail.id) + api.get(tenant_id=mock_tenant, dataset_id=dataset_id, document_id=mock_doc_detail.id) @patch("controllers.service_api.dataset.document.DocumentService") def test_get_document_metadata_only( - self, mock_doc_svc: Mock, app: Flask, mock_tenant: Mock, mock_doc_detail: Mock + self, mock_doc_svc: Mock, app: Flask, mock_tenant: str, mock_doc_detail: Document ) -> None: """Test document retrieval with metadata='only'.""" # Arrange dataset_id = str(uuid.uuid4()) - mock_dataset = Mock() - mock_dataset.id = dataset_id - mock_dataset.summary_index_setting = None + mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant, summary_index_setting=None) mock_doc_svc.get_document.return_value = mock_doc_detail @@ -678,28 +787,33 @@ def test_get_document_metadata_only( ): api = DocumentApi() with patch.object(api, "get_dataset", return_value=mock_dataset): - response = api.get(tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id=mock_doc_detail.id) + response = api.get(tenant_id=mock_tenant, dataset_id=dataset_id, document_id=mock_doc_detail.id) # Assert — metadata='only' returns only id, doc_type, doc_metadata assert response["id"] == mock_doc_detail.id - assert "doc_type" in response - assert "doc_metadata" in response - assert "name" not in response + assert response == { + "id": mock_doc_detail.id, + "doc_type": mock_doc_detail.doc_type, + "doc_metadata": None, + } @patch("controllers.service_api.dataset.document.DatasetService") @patch("controllers.service_api.dataset.document.DocumentService") def test_get_document_metadata_without( - self, mock_doc_svc: Mock, mock_dataset_svc: Mock, app: Flask, mock_tenant: Mock, mock_doc_detail: Mock + self, + mock_doc_svc: Mock, + mock_dataset_svc: Mock, + app: Flask, + mock_tenant: str, + mock_doc_detail: Document, ) -> None: """Test document retrieval with metadata='without'.""" # Arrange dataset_id = str(uuid.uuid4()) - mock_dataset = Mock() - mock_dataset.id = dataset_id - mock_dataset.summary_index_setting = None + mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant, summary_index_setting=None) mock_doc_svc.get_document.return_value = mock_doc_detail - mock_dataset_svc.get_process_rules.return_value = [] + mock_dataset_svc.get_process_rules.return_value = {"mode": "automatic", "rules": {}} # Act with app.test_request_context( @@ -707,25 +821,60 @@ def test_get_document_metadata_without( method="GET", ): api = DocumentApi() - with patch.object(api, "get_dataset", return_value=mock_dataset): - response = api.get(tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id=mock_doc_detail.id) + with ( + patch.object(api, "get_dataset", return_value=mock_dataset), + patch("models.dataset.db", _DocumentModelDbStub(_DocumentModelSessionStub([5, 5, 5, 5, 0]))), + ): + response = api.get(tenant_id=mock_tenant, dataset_id=dataset_id, document_id=mock_doc_detail.id) # Assert — metadata='without' omits doc_type / doc_metadata assert response["id"] == mock_doc_detail.id assert "doc_type" not in response assert "doc_metadata" not in response assert "name" in response + assert set(response) == { + "id", + "position", + "data_source_type", + "data_source_info", + "dataset_process_rule_id", + "dataset_process_rule", + "document_process_rule", + "name", + "created_from", + "created_by", + "created_at", + "tokens", + "indexing_status", + "completed_at", + "updated_at", + "indexing_latency", + "error", + "enabled", + "disabled_at", + "disabled_by", + "archived", + "segment_count", + "average_segment_length", + "hit_count", + "display_status", + "doc_form", + "doc_language", + "summary_index_status", + "need_summary", + } + assert response["error"] is None + assert response["disabled_at"] is None + assert response["disabled_by"] is None @patch("controllers.service_api.dataset.document.DocumentService") def test_get_document_invalid_metadata_value( - self, mock_doc_svc: Mock, app: Flask, mock_tenant: Mock, mock_doc_detail: Mock + self, mock_doc_svc: Mock, app: Flask, mock_tenant: str, mock_doc_detail: Document ) -> None: """Test error when metadata parameter has invalid value.""" # Arrange dataset_id = str(uuid.uuid4()) - mock_dataset = Mock() - mock_dataset.id = dataset_id - mock_dataset.summary_index_setting = None + mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant, summary_index_setting=None) mock_doc_svc.get_document.return_value = mock_doc_detail @@ -737,7 +886,7 @@ def test_get_document_invalid_metadata_value( api = DocumentApi() with patch.object(api, "get_dataset", return_value=mock_dataset): with pytest.raises(InvalidMetadataError): - api.get(tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id=mock_doc_detail.id) + api.get(tenant_id=mock_tenant, dataset_id=dataset_id, document_id=mock_doc_detail.id) class TestDocumentApiDelete: @@ -762,8 +911,7 @@ def test_delete_document_success(self, mock_db, mock_doc_svc, app: Flask, mock_t """Test successful document deletion.""" # Arrange dataset_id = str(uuid.uuid4()) - mock_dataset = Mock() - mock_dataset.id = dataset_id + mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant) mock_db.session.scalar.return_value = mock_dataset mock_doc_svc.get_document.return_value = mock_document @@ -777,7 +925,7 @@ def test_delete_document_success(self, mock_db, mock_doc_svc, app: Flask, mock_t ): api = DocumentApi() response = self._call_delete( - api, tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id=mock_document.id + api, tenant_id=mock_tenant, dataset_id=dataset_id, document_id=mock_document.id ) # Assert @@ -791,8 +939,7 @@ def test_delete_document_not_found(self, mock_db, mock_doc_svc, app: Flask, mock # Arrange dataset_id = str(uuid.uuid4()) document_id = str(uuid.uuid4()) - mock_dataset = Mock() - mock_dataset.id = dataset_id + mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant) mock_db.session.scalar.return_value = mock_dataset mock_doc_svc.get_document.return_value = None @@ -804,7 +951,7 @@ def test_delete_document_not_found(self, mock_db, mock_doc_svc, app: Flask, mock ): api = DocumentApi() with pytest.raises(NotFound): - self._call_delete(api, tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id=document_id) + self._call_delete(api, tenant_id=mock_tenant, dataset_id=dataset_id, document_id=document_id) @patch("controllers.service_api.dataset.document.DocumentService") @patch("controllers.service_api.dataset.document.db") @@ -812,8 +959,7 @@ def test_delete_document_archived_forbidden(self, mock_db, mock_doc_svc, app: Fl """Test ArchivedDocumentImmutableError when deleting archived document.""" # Arrange dataset_id = str(uuid.uuid4()) - mock_dataset = Mock() - mock_dataset.id = dataset_id + mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant) mock_db.session.scalar.return_value = mock_dataset mock_doc_svc.get_document.return_value = mock_document @@ -826,7 +972,7 @@ def test_delete_document_archived_forbidden(self, mock_db, mock_doc_svc, app: Fl ): api = DocumentApi() with pytest.raises(ArchivedDocumentImmutableError): - self._call_delete(api, tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id=mock_document.id) + self._call_delete(api, tenant_id=mock_tenant, dataset_id=dataset_id, document_id=mock_document.id) @patch("controllers.service_api.dataset.document.DocumentService") @patch("controllers.service_api.dataset.document.db") @@ -844,7 +990,7 @@ def test_delete_document_dataset_not_found(self, mock_db, mock_doc_svc, app: Fla ): api = DocumentApi() with pytest.raises(ValueError, match="Dataset does not exist."): - self._call_delete(api, tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id=document_id) + self._call_delete(api, tenant_id=mock_tenant, dataset_id=dataset_id, document_id=document_id) class TestDocumentListApi: @@ -857,17 +1003,18 @@ def test_list_documents_success(self, mock_db, mock_doc_svc, app: Flask, mock_te # Arrange mock_db.session.scalar.return_value = mock_dataset - mock_pagination = Mock() - mock_pagination.items = [ + documents = [ make_serializable_document( id="doc-1", name="Document 1", - doc_metadata_details=[{"id": "meta-1", "name": "amount", "type": "number", "value": 42}], + tenant_id=mock_tenant, + dataset_id=mock_dataset.id, + ), + make_serializable_document( + id="doc-2", name="Document 2", tenant_id=mock_tenant, dataset_id=mock_dataset.id ), - make_serializable_document(id="doc-2", name="Document 2"), ] - mock_pagination.total = 2 - mock_db.paginate.return_value = mock_pagination + mock_db.paginate.return_value = _PaginationRecord(items=documents, total=2) mock_doc_svc.enrich_documents_with_summary_index_status.return_value = None @@ -877,17 +1024,17 @@ def test_list_documents_success(self, mock_db, mock_doc_svc, app: Flask, mock_te method="GET", ): api = DocumentListApi() - response = api.get(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) + with patch("models.dataset.db", _DocumentModelDbStub(_DocumentModelSessionStub([0, 0]))): + response = api.get(tenant_id=mock_tenant, dataset_id=mock_dataset.id) # Assert - assert "data" in response - assert "total" in response - assert response["page"] == 1 - assert response["limit"] == 20 - assert response["total"] == 2 - assert response["data"][0]["id"] == "doc-1" - assert response["data"][0]["data_source_info"] == {"upload_file_id": "file-1"} - assert response["data"][0]["doc_metadata"][0]["value"] == 42 + assert response == { + "data": [_expected_document_response(documents[0]), _expected_document_response(documents[1])], + "has_more": False, + "limit": 20, + "total": 2, + "page": 1, + } assert "data_source_info_dict" not in response["data"][0] assert "doc_metadata_details" not in response["data"][0] @@ -904,7 +1051,7 @@ def test_list_documents_dataset_not_found(self, mock_db, app: Flask, mock_tenant ): api = DocumentListApi() with pytest.raises(NotFound): - api.get(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) + api.get(tenant_id=mock_tenant, dataset_id=mock_dataset.id) class TestDocumentIndexingStatusApi: @@ -916,20 +1063,14 @@ def test_get_indexing_status_success(self, mock_db, mock_doc_svc, app: Flask, mo """Test successful indexing status retrieval.""" # Arrange batch_id = "batch_123" - mock_doc = Mock() - mock_doc.id = str(uuid.uuid4()) - mock_doc.is_paused = False - mock_doc.indexing_status = "completed" - mock_doc.processing_started_at = None - mock_doc.parsing_completed_at = None - mock_doc.cleaning_completed_at = None - mock_doc.splitting_completed_at = None - mock_doc.completed_at = None - mock_doc.paused_at = None - mock_doc.error = None - mock_doc.stopped_at = None - - mock_doc_svc.get_batch_documents.return_value = [mock_doc] + document = make_serializable_document( + id=str(uuid.uuid4()), + tenant_id=mock_tenant, + dataset_id=mock_dataset.id, + completed_at=datetime(2021, 1, 1, 0, 0, 4, tzinfo=UTC), + ) + + mock_doc_svc.get_batch_documents.return_value = [document] # scalar() called 3 times: dataset lookup, completed_segments count, total_segments count mock_db.session.scalar.side_effect = [mock_dataset, 5, 5] @@ -940,17 +1081,27 @@ def test_get_indexing_status_success(self, mock_db, mock_doc_svc, app: Flask, mo method="GET", ): api = DocumentIndexingStatusApi() - response = api.get(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id, batch=batch_id) + response = api.get(tenant_id=mock_tenant, dataset_id=mock_dataset.id, batch=batch_id) # Assert - assert "data" in response - assert len(response["data"]) == 1 - item = response["data"][0] - assert item["id"] == mock_doc.id - assert item["indexing_status"] == "completed" - assert item["completed_segments"] == 5 - assert item["total_segments"] == 5 - assert item["processing_started_at"] is None + assert response == { + "data": [ + { + "id": document.id, + "indexing_status": "completed", + "processing_started_at": 1609459200, + "parsing_completed_at": 1609459201, + "cleaning_completed_at": 1609459202, + "splitting_completed_at": 1609459203, + "completed_at": 1609459204, + "paused_at": None, + "error": None, + "stopped_at": None, + "completed_segments": 5, + "total_segments": 5, + } + ] + } @patch("controllers.service_api.dataset.document.db") def test_get_indexing_status_dataset_not_found(self, mock_db, app: Flask, mock_tenant, mock_dataset): @@ -966,7 +1117,7 @@ def test_get_indexing_status_dataset_not_found(self, mock_db, app: Flask, mock_t ): api = DocumentIndexingStatusApi() with pytest.raises(NotFound): - api.get(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id, batch=batch_id) + api.get(tenant_id=mock_tenant, dataset_id=mock_dataset.id, batch=batch_id) @patch("controllers.service_api.dataset.document.DocumentService") @patch("controllers.service_api.dataset.document.db") @@ -986,7 +1137,7 @@ def test_get_indexing_status_documents_not_found( ): api = DocumentIndexingStatusApi() with pytest.raises(NotFound): - api.get(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id, batch=batch_id) + api.get(tenant_id=mock_tenant, dataset_id=mock_dataset.id, batch=batch_id) class TestDocumentAddByTextApi: @@ -1049,7 +1200,7 @@ def test_create_document_by_text_success( ): """Test successful document creation by text.""" # Arrange — neutralise billing decorators - self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_db.session.scalar.return_value = mock_dataset mock_dataset.indexing_technique = "economy" @@ -1080,16 +1231,14 @@ def test_create_document_by_text_success( headers={"Authorization": "Bearer test_token"}, ): api = DocumentAddByTextApi() - response, status = api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) + with patch("models.dataset.db", _DocumentModelDbStub(_DocumentModelSessionStub([object(), 0]))): + response, status = api.post(tenant_id=mock_tenant, dataset_id=mock_dataset.id) # Assert - assert status == 200 - assert "document" in response - assert "batch" in response - assert response["batch"] == "batch_123" - assert response["document"]["id"] == "doc-create-text" - assert response["document"]["data_source_info"] == {"upload_file_id": "file-1"} - assert response["document"]["doc_metadata"] == [] + assert (response, status) == ( + {"document": _expected_document_response(mock_doc), "batch": "batch_123"}, + 200, + ) assert "data_source_info_dict" not in response["document"] @patch("controllers.service_api.wraps.FeatureService") @@ -1100,7 +1249,7 @@ def test_create_document_dataset_not_found( ): """Test ValueError when dataset not found.""" # Arrange — neutralise billing decorators - self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_db.session.scalar.return_value = None @@ -1113,7 +1262,7 @@ def test_create_document_dataset_not_found( ): api = DocumentAddByTextApi() with pytest.raises(ValueError, match="Dataset does not exist."): - api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) + api.post(tenant_id=mock_tenant, dataset_id=mock_dataset.id) @patch("controllers.service_api.wraps.FeatureService") @patch("controllers.service_api.wraps.validate_and_get_api_token") @@ -1128,7 +1277,7 @@ def test_create_document_missing_indexing_technique( document creation paths instead of leaking a ``KeyError`` from the dumped payload dict. """ # Arrange — neutralise billing decorators - self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_dataset.indexing_technique = None mock_db.session.scalar.return_value = mock_dataset @@ -1142,7 +1291,7 @@ def test_create_document_missing_indexing_technique( ): api = DocumentAddByTextApi() with pytest.raises(ValueError, match="indexing_technique is required."): - api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) + api.post(tenant_id=mock_tenant, dataset_id=mock_dataset.id) class TestArchivedDocumentImmutableError: @@ -1235,9 +1384,8 @@ def test_update_by_text_success( mock_dataset, ): """Test successful document update by text.""" - _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_dataset.indexing_technique = "economy" - mock_dataset.latest_process_rule = Mock() mock_db.session.scalar.return_value = mock_dataset mock_current_user.id = "user-1" @@ -1257,17 +1405,17 @@ def test_update_by_text_success( headers={"Authorization": "Bearer test_token"}, ): api = DocumentUpdateByTextApi() - response, status = api.post( - tenant_id=mock_tenant.id, - dataset_id=mock_dataset.id, - document_id=doc_id, - ) + with patch("models.dataset.db", _DocumentModelDbStub(_DocumentModelSessionStub([object(), 0]))): + response, status = api.post( + tenant_id=mock_tenant, + dataset_id=mock_dataset.id, + document_id=doc_id, + ) - assert status == 200 - assert "document" in response - assert response["batch"] == "batch-1" - assert response["document"]["id"] == "doc-update-text" - assert response["document"]["doc_metadata"] == [] + assert (response, status) == ( + {"document": _expected_document_response(mock_document), "batch": "batch-1"}, + 200, + ) @patch("controllers.service_api.dataset.document.db") @patch("controllers.service_api.wraps.FeatureService") @@ -1282,7 +1430,7 @@ def test_update_by_text_dataset_not_found( mock_dataset, ): """Test ValueError when dataset not found.""" - _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_db.session.scalar.return_value = None doc_id = str(uuid.uuid4()) @@ -1295,7 +1443,7 @@ def test_update_by_text_dataset_not_found( api = DocumentUpdateByTextApi() with pytest.raises(ValueError, match="Dataset does not exist"): api.post( - tenant_id=mock_tenant.id, + tenant_id=mock_tenant, dataset_id=mock_dataset.id, document_id=doc_id, ) @@ -1327,12 +1475,10 @@ def test_add_by_file_success_serializes_document_and_batch_shape( mock_dataset, ): """Test successful document creation by file.""" - _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_dataset.provider = "vendor" mock_dataset.indexing_technique = "economy" mock_dataset.chunk_structure = None - mock_dataset.latest_process_rule = Mock() - mock_dataset.created_by_account = Mock() mock_db.session.scalar.return_value = mock_dataset mock_current_user.id = "user-1" @@ -1355,13 +1501,13 @@ def test_add_by_file_success_serializes_document_and_batch_shape( headers={"Authorization": "Bearer test_token"}, ): api = DocumentAddByFileApi() - response, status = api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) + with patch("models.dataset.db", _DocumentModelDbStub(_DocumentModelSessionStub([object(), 0]))): + response, status = api.post(tenant_id=mock_tenant, dataset_id=mock_dataset.id) - assert status == 200 - assert response["batch"] == "batch-file" - assert response["document"]["id"] == "doc-create-file" - assert response["document"]["data_source_info"] == {"upload_file_id": "file-1"} - assert response["document"]["doc_metadata"] == [] + assert (response, status) == ( + {"document": _expected_document_response(mock_document), "batch": "batch-file"}, + 200, + ) @patch("controllers.service_api.dataset.document.db") @patch("controllers.service_api.wraps.FeatureService") @@ -1376,7 +1522,7 @@ def test_add_by_file_dataset_not_found( mock_dataset, ): """Test ValueError when dataset not found.""" - _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_db.session.scalar.return_value = None from io import BytesIO @@ -1391,7 +1537,7 @@ def test_add_by_file_dataset_not_found( ): api = DocumentAddByFileApi() with pytest.raises(ValueError, match="Dataset does not exist"): - api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) + api.post(tenant_id=mock_tenant, dataset_id=mock_dataset.id) @patch("controllers.service_api.dataset.document.db") @patch("controllers.service_api.wraps.FeatureService") @@ -1406,7 +1552,7 @@ def test_add_by_file_external_dataset( mock_dataset, ): """Test ValueError when dataset is external.""" - _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_dataset.provider = "external" mock_db.session.scalar.return_value = mock_dataset @@ -1422,7 +1568,7 @@ def test_add_by_file_external_dataset( ): api = DocumentAddByFileApi() with pytest.raises(ValueError, match="External datasets"): - api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) + api.post(tenant_id=mock_tenant, dataset_id=mock_dataset.id) @patch("controllers.service_api.dataset.document.db") @patch("controllers.service_api.wraps.FeatureService") @@ -1439,7 +1585,7 @@ def test_add_by_file_no_file_uploaded( """Test NoFileUploadedError when no file in request.""" from controllers.common.errors import NoFileUploadedError - _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_dataset.provider = "vendor" mock_dataset.indexing_technique = "economy" mock_dataset.chunk_structure = None @@ -1454,7 +1600,7 @@ def test_add_by_file_no_file_uploaded( ): api = DocumentAddByFileApi() with pytest.raises(NoFileUploadedError): - api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) + api.post(tenant_id=mock_tenant, dataset_id=mock_dataset.id) @patch("controllers.service_api.dataset.document.db") @patch("controllers.service_api.wraps.FeatureService") @@ -1469,7 +1615,7 @@ def test_add_by_file_missing_indexing_technique( mock_dataset, ): """Test ValueError when indexing_technique is missing.""" - _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_dataset.provider = "vendor" mock_dataset.indexing_technique = None mock_dataset.chunk_structure = None @@ -1487,7 +1633,7 @@ def test_add_by_file_missing_indexing_technique( ): api = DocumentAddByFileApi() with pytest.raises(ValueError, match="indexing_technique is required"): - api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) + api.post(tenant_id=mock_tenant, dataset_id=mock_dataset.id) class TestDocumentUpdateByFileApiPatch: @@ -1512,8 +1658,11 @@ def test_update_by_file_deprecated_aliases_delegate_to_shared_handler( mock_dataset, ): """Test legacy POST aliases still dispatch while marked deprecated.""" - _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) - mock_update_document_by_file.return_value = ({"document": {"id": "doc-1"}, "batch": "batch-1"}, 200) + _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) + mock_update_document_by_file.return_value = ( + make_serializable_document(id="doc-1", batch="batch-1"), + "batch-1", + ) doc_id = str(uuid.uuid4()) with app.test_request_context( @@ -1522,16 +1671,19 @@ def test_update_by_file_deprecated_aliases_delegate_to_shared_handler( headers={"Authorization": "Bearer test_token"}, ): api = DeprecatedDocumentUpdateByFileApi() - response, status = api.post( - tenant_id=mock_tenant.id, - dataset_id=mock_dataset.id, - document_id=doc_id, - ) + with patch("models.dataset.db", _DocumentModelDbStub(_DocumentModelSessionStub([0]))): + response, status = api.post( + tenant_id=mock_tenant, + dataset_id=mock_dataset.id, + document_id=doc_id, + ) - assert status == 200 - assert response["batch"] == "batch-1" + assert (response, status) == ( + {"document": _expected_document_response(mock_update_document_by_file.return_value[0]), "batch": "batch-1"}, + 200, + ) mock_update_document_by_file.assert_called_once_with( - tenant_id=mock_tenant.id, + tenant_id=mock_tenant, dataset_id=mock_dataset.id, document_id=doc_id, ) @@ -1549,7 +1701,7 @@ def test_update_by_file_dataset_not_found( mock_dataset, ): """Test ValueError when dataset not found.""" - _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_db.session.scalar.return_value = None from io import BytesIO @@ -1566,7 +1718,7 @@ def test_update_by_file_dataset_not_found( api = DocumentApi() with pytest.raises(ValueError, match="Dataset does not exist"): api.patch( - tenant_id=mock_tenant.id, + tenant_id=mock_tenant, dataset_id=mock_dataset.id, document_id=doc_id, ) @@ -1584,7 +1736,7 @@ def test_update_by_file_external_dataset( mock_dataset, ): """Test ValueError when dataset is external.""" - _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_dataset.provider = "external" mock_db.session.scalar.return_value = mock_dataset @@ -1602,7 +1754,7 @@ def test_update_by_file_external_dataset( api = DocumentApi() with pytest.raises(ValueError, match="External datasets"): api.patch( - tenant_id=mock_tenant.id, + tenant_id=mock_tenant, dataset_id=mock_dataset.id, document_id=doc_id, ) @@ -1626,12 +1778,10 @@ def test_update_by_file_success( mock_dataset, ): """Test successful document update by file.""" - _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) + _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant) mock_dataset.indexing_technique = "economy" mock_dataset.provider = "vendor" mock_dataset.chunk_structure = None - mock_dataset.latest_process_rule = Mock() - mock_dataset.created_by_account = Mock() mock_db.session.scalar.return_value = mock_dataset mock_current_user.id = "user-1" @@ -1655,14 +1805,14 @@ def test_update_by_file_success( headers={"Authorization": "Bearer test_token"}, ): api = DocumentApi() - response, status = api.patch( - tenant_id=mock_tenant.id, - dataset_id=mock_dataset.id, - document_id=doc_id, - ) + with patch("models.dataset.db", _DocumentModelDbStub(_DocumentModelSessionStub([object(), 0]))): + response, status = api.patch( + tenant_id=mock_tenant, + dataset_id=mock_dataset.id, + document_id=doc_id, + ) - assert status == 200 - assert "document" in response - assert response["batch"] == "batch-1" - assert response["document"]["id"] == "doc-update-file" - assert response["document"]["data_source_info"] == {"upload_file_id": "file-1"} + assert (response, status) == ( + {"document": _expected_document_response(mock_document), "batch": "batch-1"}, + 200, + ) diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py b/api/tests/unit_tests/controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py index a8dd8523acb3e7..bfda3e23b32d6b 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py @@ -2,9 +2,10 @@ Unit tests for Service API knowledge pipeline file-upload serialization. """ -import importlib.util from datetime import UTC, datetime -from pathlib import Path + +from controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow import PipelineUploadFileResponse +from libs.helper import dump_response class FakeUploadFile: @@ -17,21 +18,7 @@ class FakeUploadFile: created_at: datetime | None -def _load_serialize_upload_file(): - api_dir = Path(__file__).resolve().parents[5] - serializers_path = api_dir / "controllers" / "service_api" / "dataset" / "rag_pipeline" / "serializers.py" - - spec = importlib.util.spec_from_file_location("rag_pipeline_serializers", serializers_path) - assert spec - assert spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) # type: ignore[attr-defined] - return module.serialize_upload_file - - def test_file_upload_created_at_is_isoformat_string(): - serialize_upload_file = _load_serialize_upload_file() - created_at = datetime(2026, 2, 8, 12, 0, 0, tzinfo=UTC) upload_file = FakeUploadFile() upload_file.id = "file-1" @@ -42,13 +29,11 @@ def test_file_upload_created_at_is_isoformat_string(): upload_file.created_by = "account-1" upload_file.created_at = created_at - result = serialize_upload_file(upload_file) + result = dump_response(PipelineUploadFileResponse, upload_file) assert result["created_at"] == created_at.isoformat() def test_file_upload_created_at_none_serializes_to_null(): - serialize_upload_file = _load_serialize_upload_file() - upload_file = FakeUploadFile() upload_file.id = "file-1" upload_file.name = "test.pdf" @@ -58,5 +43,5 @@ def test_file_upload_created_at_none_serializes_to_null(): upload_file.created_by = "account-1" upload_file.created_at = None - result = serialize_upload_file(upload_file) + result = dump_response(PipelineUploadFileResponse, upload_file) assert result["created_at"] is None diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index 0caeae2cee4fdf..1a602813eaa6f9 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from datetime import UTC, datetime from types import SimpleNamespace from typing import Any @@ -112,7 +111,7 @@ def get_definition(self): chat_color_theme_inverted=False, copyright=None, privacy_policy=None, - custom_disclaimer=None, + custom_disclaimer="", prompt_public=False, show_workflow_steps=True, use_icon_as_answer_icon=False, @@ -138,7 +137,7 @@ def get_definition(self): with app.test_request_context("/api/form/human_input/token-1", method="GET"): response = HumanInputFormApi().get("token-1") - body = json.loads(response.get_data(as_text=True)) + body = response assert set(body.keys()) == { "site", "form_content", @@ -167,7 +166,7 @@ def get_definition(self): "description": "desc", "copyright": None, "privacy_policy": None, - "custom_disclaimer": None, + "custom_disclaimer": "", "default_language": "en", "prompt_public": False, "show_workflow_steps": True, @@ -256,7 +255,7 @@ def get_definition(self): chat_color_theme_inverted=False, copyright=None, privacy_policy=None, - custom_disclaimer=None, + custom_disclaimer="", prompt_public=False, show_workflow_steps=True, use_icon_as_answer_icon=False, @@ -277,7 +276,7 @@ def mock_get_features(tenant_id: str, exclude_vector_space: bool = False): with app.test_request_context("/api/form/human_input/token-1", method="GET"): response = HumanInputFormApi().get("token-1") - body = json.loads(response.get_data(as_text=True)) + body = response assert body["inputs"] == [input_config.model_dump(mode="json") for input_config in runtime_inputs] service_mock.resolve_form_inputs.assert_called_once_with(form) @@ -380,7 +379,7 @@ def get_definition(self): chat_color_theme_inverted=False, copyright=None, privacy_policy=None, - custom_disclaimer=None, + custom_disclaimer="", prompt_public=False, show_workflow_steps=True, use_icon_as_answer_icon=False, @@ -403,7 +402,7 @@ def get_definition(self): with app.test_request_context("/api/form/human_input/token-1", method="GET"): response = HumanInputFormApi().get("token-1") - body = json.loads(response.get_data(as_text=True)) + body = response assert set(body.keys()) == { "site", "form_content", @@ -432,7 +431,7 @@ def get_definition(self): "description": "desc", "copyright": None, "privacy_policy": None, - "custom_disclaimer": None, + "custom_disclaimer": "", "default_language": "en", "prompt_public": False, "show_workflow_steps": True, diff --git a/api/tests/unit_tests/controllers/web/test_passport.py b/api/tests/unit_tests/controllers/web/test_passport.py index 58d58626b22bd2..ebc64d9452185d 100644 --- a/api/tests/unit_tests/controllers/web/test_passport.py +++ b/api/tests/unit_tests/controllers/web/test_passport.py @@ -34,12 +34,11 @@ def test_decode_enterprise_webapp_user_id_valid(monkeypatch: pytest.MonkeyPatch) def test_exchange_token_public_flow(monkeypatch: pytest.MonkeyPatch) -> None: site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal") app_model = SimpleNamespace(id="a1", status="normal", enable_site=True) + call_state = {"calls": 0} def _scalar_side_effect(*_args, **_kwargs): - if not hasattr(_scalar_side_effect, "calls"): - _scalar_side_effect.calls = 0 - _scalar_side_effect.calls += 1 - return site if _scalar_side_effect.calls == 1 else app_model + call_state["calls"] += 1 + return site if call_state["calls"] == 1 else app_model db_session = SimpleNamespace(scalar=_scalar_side_effect) monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session)) @@ -53,12 +52,11 @@ def _scalar_side_effect(*_args, **_kwargs): def test_exchange_token_requires_external(monkeypatch: pytest.MonkeyPatch) -> None: site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal") app_model = SimpleNamespace(id="a1", status="normal", enable_site=True) + call_state = {"calls": 0} def _scalar_side_effect(*_args, **_kwargs): - if not hasattr(_scalar_side_effect, "calls"): - _scalar_side_effect.calls = 0 - _scalar_side_effect.calls += 1 - return site if _scalar_side_effect.calls == 1 else app_model + call_state["calls"] += 1 + return site if call_state["calls"] == 1 else app_model db_session = SimpleNamespace(scalar=_scalar_side_effect) monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session)) @@ -71,14 +69,13 @@ def _scalar_side_effect(*_args, **_kwargs): def test_exchange_token_missing_session_id(monkeypatch: pytest.MonkeyPatch) -> None: site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal") app_model = SimpleNamespace(id="a1", status="normal", enable_site=True, tenant_id="t1") + call_state = {"calls": 0} def _scalar_side_effect(*_args, **_kwargs): - if not hasattr(_scalar_side_effect, "calls"): - _scalar_side_effect.calls = 0 - _scalar_side_effect.calls += 1 - if _scalar_side_effect.calls == 1: + call_state["calls"] += 1 + if call_state["calls"] == 1: return site - if _scalar_side_effect.calls == 2: + if call_state["calls"] == 2: return app_model return None diff --git a/api/tests/unit_tests/controllers/web/test_web_login.py b/api/tests/unit_tests/controllers/web/test_web_login.py index bfffd5cbb2c278..984be6ddba94f1 100644 --- a/api/tests/unit_tests/controllers/web/test_web_login.py +++ b/api/tests/unit_tests/controllers/web/test_web_login.py @@ -95,7 +95,7 @@ def test_should_normalize_email_before_validating( ): response = EmailCodeLoginApi().post() - assert response.get_json() == {"result": "success", "data": {"access_token": "new-access-token"}} + assert response == {"result": "success", "data": {"access_token": "new-access-token"}} mock_get_user.assert_called_once_with("User@Example.com") mock_revoke_token.assert_called_once_with("token-123") mock_login.assert_called_once() @@ -115,7 +115,7 @@ def test_login_success(self, mock_auth: MagicMock, mock_login: MagicMock, app: F ): response = LoginApi().post() - assert response.get_json()["data"]["access_token"] == "access-tok" + assert response["data"]["access_token"] == "access-tok" mock_auth.assert_called_once() @patch( diff --git a/api/tests/unit_tests/controllers/web/test_web_passport.py b/api/tests/unit_tests/controllers/web/test_web_passport.py index 19b1d8504a0c4f..4e1a24a4da27ab 100644 --- a/api/tests/unit_tests/controllers/web/test_web_passport.py +++ b/api/tests/unit_tests/controllers/web/test_web_passport.py @@ -33,6 +33,7 @@ def test_valid_token_returns_decoded(self, mock_passport_cls: MagicMock) -> None "user_id": "u1", } result = decode_enterprise_webapp_user_id("valid-jwt") + assert result is not None assert result["user_id"] == "u1" @patch("controllers.web.passport.PassportService") @@ -143,7 +144,7 @@ def test_creates_new_end_user_when_no_user_id( with app.test_request_context("/passport", headers={"X-App-Code": "code1"}): response = PassportResource().get() - assert response.get_json()["access_token"] == "issued-token" + assert response["access_token"] == "issued-token" mock_db.session.add.assert_called_once() mock_db.session.commit.assert_called_once() @@ -167,7 +168,7 @@ def test_reuses_existing_end_user_when_user_id_provided( with app.test_request_context("/passport?user_id=sess-existing", headers={"X-App-Code": "code1"}): response = PassportResource().get() - assert response.get_json()["access_token"] == "reused-token" + assert response["access_token"] == "reused-token" # Should not create a new end user mock_db.session.add.assert_not_called() diff --git a/api/tests/unit_tests/fields/test_snippet_fields.py b/api/tests/unit_tests/fields/test_snippet_fields.py index ad8ee6e8f0bb68..233c4e5da90262 100644 --- a/api/tests/unit_tests/fields/test_snippet_fields.py +++ b/api/tests/unit_tests/fields/test_snippet_fields.py @@ -1,8 +1,7 @@ from types import SimpleNamespace -from flask_restx import marshal - -from fields.snippet_fields import snippet_list_fields +from fields.snippet_fields import SnippetListItemResponse +from libs.helper import dump_response def test_snippet_list_fields_include_author_name() -> None: @@ -23,6 +22,6 @@ def test_snippet_list_fields_include_author_name() -> None: updated_at=None, ) - result = marshal(snippet, snippet_list_fields) + result = dump_response(SnippetListItemResponse, snippet) assert result["author_name"] == "Alice" diff --git a/api/tests/unit_tests/libs/test_helper.py b/api/tests/unit_tests/libs/test_helper.py index 1a93dbbca1372f..1bd36d134d6005 100644 --- a/api/tests/unit_tests/libs/test_helper.py +++ b/api/tests/unit_tests/libs/test_helper.py @@ -1,8 +1,9 @@ from datetime import datetime +from typing import Any, cast import pytest -from libs.helper import OptionalTimestampField, escape_like_pattern, extract_tenant_id +from libs.helper import escape_like_pattern, extract_tenant_id, to_timestamp from models.account import Account from models.model import EndUser @@ -42,7 +43,7 @@ def test_extract_tenant_id_from_enduser_without_tenant(self): """Test extracting tenant_id from EndUser without tenant_id.""" # Create a mock EndUser object end_user = EndUser() - end_user.tenant_id = None + cast(Any, end_user).tenant_id = None tenant_id = extract_tenant_id(end_user) assert tenant_id is None @@ -52,32 +53,29 @@ def test_extract_tenant_id_with_invalid_user_type(self): invalid_user = "not_a_user_object" with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"): - extract_tenant_id(invalid_user) + extract_tenant_id(cast(Any, invalid_user)) def test_extract_tenant_id_with_none_user(self): """Test extracting tenant_id with None user raises ValueError.""" with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"): - extract_tenant_id(None) + extract_tenant_id(cast(Any, None)) def test_extract_tenant_id_with_dict_user(self): """Test extracting tenant_id with dict user raises ValueError.""" dict_user = {"id": "123", "tenant_id": "456"} with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"): - extract_tenant_id(dict_user) + extract_tenant_id(cast(Any, dict_user)) -class TestOptionalTimestampField: +class TestToTimestamp: def test_format_returns_none_for_none(self): - field = OptionalTimestampField() - - assert field.format(None) is None + assert to_timestamp(None) is None def test_format_returns_unix_timestamp_for_datetime(self): - field = OptionalTimestampField() value = datetime(2024, 1, 2, 3, 4, 5) - assert field.format(value) == int(value.timestamp()) + assert to_timestamp(value) == int(value.timestamp()) class TestEscapeLikePattern: @@ -111,7 +109,7 @@ def test_escape_empty_string(self): def test_escape_none_handling(self): """Test escaping None returns None (falsy check handles it).""" # The function checks `if not pattern`, so None is falsy and returns as-is - result = escape_like_pattern(None) + result = escape_like_pattern(cast(Any, None)) assert result is None def test_escape_normal_string_no_change(self): diff --git a/api/tests/unit_tests/services/controller_api.py b/api/tests/unit_tests/services/controller_api.py index 10b80fb92f6301..48805da884e6fc 100644 --- a/api/tests/unit_tests/services/controller_api.py +++ b/api/tests/unit_tests/services/controller_api.py @@ -1009,8 +1009,7 @@ def test_get_external_knowledge_apis_success(self, client_list: FlaskClient, moc # 4. Provide default values when parameters are missing # 5. Raise BadRequest exceptions when validation fails # -# Response formatting is handled by Flask-RESTX's marshal_with decorator -# or marshal function, which: +# Response formatting is handled by controller response schemas, which: # # 1. Formats response data according to defined models # 2. Handles nested objects and lists diff --git a/packages/contracts/generated/api/console/account/orpc.gen.ts b/packages/contracts/generated/api/console/account/orpc.gen.ts index a9261036675841..e719bbfabd097a 100644 --- a/packages/contracts/generated/api/console/account/orpc.gen.ts +++ b/packages/contracts/generated/api/console/account/orpc.gen.ts @@ -27,8 +27,6 @@ import { zPostAccountDeleteFeedbackBody, zPostAccountDeleteFeedbackResponse, zPostAccountDeleteResponse, - zPostAccountEducationBody, - zPostAccountEducationResponse, zPostAccountInitBody, zPostAccountInitResponse, zPostAccountInterfaceLanguageBody, @@ -222,25 +220,13 @@ export const get5 = oc }) .output(zGetAccountEducationResponse) -export const post8 = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAccountEducation', - path: '/account/education', - tags: ['console'], - }) - .input(z.object({ body: zPostAccountEducationBody })) - .output(zPostAccountEducationResponse) - export const education = { get: get5, - post: post8, autocomplete, verify: verify2, } -export const post9 = oc +export const post8 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -252,7 +238,7 @@ export const post9 = oc .output(zPostAccountInitResponse) export const init = { - post: post9, + post: post8, } export const get6 = oc @@ -269,7 +255,7 @@ export const integrates = { get: get6, } -export const post10 = oc +export const post9 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -281,10 +267,10 @@ export const post10 = oc .output(zPostAccountInterfaceLanguageResponse) export const interfaceLanguage = { - post: post10, + post: post9, } -export const post11 = oc +export const post10 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -296,10 +282,10 @@ export const post11 = oc .output(zPostAccountInterfaceThemeResponse) export const interfaceTheme = { - post: post11, + post: post10, } -export const post12 = oc +export const post11 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -311,10 +297,10 @@ export const post12 = oc .output(zPostAccountNameResponse) export const name = { - post: post12, + post: post11, } -export const post13 = oc +export const post12 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -326,7 +312,7 @@ export const post13 = oc .output(zPostAccountPasswordResponse) export const password = { - post: post13, + post: post12, } export const get7 = oc @@ -343,7 +329,7 @@ export const profile = { get: get7, } -export const post14 = oc +export const post13 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -355,7 +341,7 @@ export const post14 = oc .output(zPostAccountTimezoneResponse) export const timezone = { - post: post14, + post: post13, } export const account = { diff --git a/packages/contracts/generated/api/console/account/types.gen.ts b/packages/contracts/generated/api/console/account/types.gen.ts index cdd45925fb299c..390e7caeffa9a9 100644 --- a/packages/contracts/generated/api/console/account/types.gen.ts +++ b/packages/contracts/generated/api/console/account/types.gen.ts @@ -12,7 +12,7 @@ export type AccountAvatarPayload = { avatar: string } -export type Account = { +export type AccountResponse = { avatar?: string | null readonly avatar_url: string | null created_at?: number | null @@ -81,16 +81,6 @@ export type EducationStatusResponse = { result?: boolean | null } -export type EducationActivatePayload = { - institution: string - role: string - token: string -} - -export type EducationActivateResponse = { - [key: string]: unknown -} - export type EducationAutocompleteResponse = { curr_page?: number | null data?: Array @@ -140,7 +130,7 @@ export type AccountIntegrateResponse = { provider: string } -export type AccountWritable = { +export type AccountResponseWritable = { avatar?: string | null created_at?: number | null email: string @@ -177,7 +167,7 @@ export type PostAccountAvatarData = { } export type PostAccountAvatarResponses = { - 200: Account + 200: AccountResponse } export type PostAccountAvatarResponse = PostAccountAvatarResponses[keyof PostAccountAvatarResponses] @@ -218,7 +208,7 @@ export type PostAccountChangeEmailResetData = { } export type PostAccountChangeEmailResetResponses = { - 200: Account + 200: AccountResponse } export type PostAccountChangeEmailResetResponse @@ -293,20 +283,6 @@ export type GetAccountEducationResponses = { export type GetAccountEducationResponse = GetAccountEducationResponses[keyof GetAccountEducationResponses] -export type PostAccountEducationData = { - body: EducationActivatePayload - path?: never - query?: never - url: '/account/education' -} - -export type PostAccountEducationResponses = { - 200: EducationActivateResponse -} - -export type PostAccountEducationResponse - = PostAccountEducationResponses[keyof PostAccountEducationResponses] - export type GetAccountEducationAutocompleteData = { body?: never path?: never @@ -374,7 +350,7 @@ export type PostAccountInterfaceLanguageData = { } export type PostAccountInterfaceLanguageResponses = { - 200: Account + 200: AccountResponse } export type PostAccountInterfaceLanguageResponse @@ -388,7 +364,7 @@ export type PostAccountInterfaceThemeData = { } export type PostAccountInterfaceThemeResponses = { - 200: Account + 200: AccountResponse } export type PostAccountInterfaceThemeResponse @@ -402,7 +378,7 @@ export type PostAccountNameData = { } export type PostAccountNameResponses = { - 200: Account + 200: AccountResponse } export type PostAccountNameResponse = PostAccountNameResponses[keyof PostAccountNameResponses] @@ -415,7 +391,7 @@ export type PostAccountPasswordData = { } export type PostAccountPasswordResponses = { - 200: Account + 200: AccountResponse } export type PostAccountPasswordResponse @@ -429,7 +405,7 @@ export type GetAccountProfileData = { } export type GetAccountProfileResponses = { - 200: Account + 200: AccountResponse } export type GetAccountProfileResponse = GetAccountProfileResponses[keyof GetAccountProfileResponses] @@ -442,7 +418,7 @@ export type PostAccountTimezoneData = { } export type PostAccountTimezoneResponses = { - 200: Account + 200: AccountResponse } export type PostAccountTimezoneResponse diff --git a/packages/contracts/generated/api/console/account/zod.gen.ts b/packages/contracts/generated/api/console/account/zod.gen.ts index 9951efc8d9f348..feb3a45d78b7e4 100644 --- a/packages/contracts/generated/api/console/account/zod.gen.ts +++ b/packages/contracts/generated/api/console/account/zod.gen.ts @@ -17,9 +17,9 @@ export const zAccountAvatarPayload = z.object({ }) /** - * Account + * AccountResponse */ -export const zAccount = z.object({ +export const zAccountResponse = z.object({ avatar: z.string().nullish(), avatar_url: z.string().nullable(), created_at: z.int().nullish(), @@ -118,20 +118,6 @@ export const zEducationStatusResponse = z.object({ result: z.boolean().nullish(), }) -/** - * EducationActivatePayload - */ -export const zEducationActivatePayload = z.object({ - institution: z.string(), - role: z.string(), - token: z.string(), -}) - -/** - * EducationActivateResponse - */ -export const zEducationActivateResponse = z.record(z.string(), z.unknown()) - /** * EducationAutocompleteResponse */ @@ -212,9 +198,9 @@ export const zAccountIntegrateListResponse = z.object({ }) /** - * Account + * AccountResponse */ -export const zAccountWritable = z.object({ +export const zAccountResponseWritable = z.object({ avatar: z.string().nullish(), created_at: z.int().nullish(), email: z.string(), @@ -242,7 +228,7 @@ export const zPostAccountAvatarBody = zAccountAvatarPayload /** * Success */ -export const zPostAccountAvatarResponse = zAccount +export const zPostAccountAvatarResponse = zAccountResponse export const zPostAccountChangeEmailBody = zChangeEmailSendPayload @@ -263,7 +249,7 @@ export const zPostAccountChangeEmailResetBody = zChangeEmailResetPayload /** * Success */ -export const zPostAccountChangeEmailResetResponse = zAccount +export const zPostAccountChangeEmailResetResponse = zAccountResponse export const zPostAccountChangeEmailValidityBody = zChangeEmailValidityPayload @@ -296,13 +282,6 @@ export const zGetAccountDeleteVerifyResponse = zSimpleResultDataResponse */ export const zGetAccountEducationResponse = zEducationStatusResponse -export const zPostAccountEducationBody = zEducationActivatePayload - -/** - * Success - */ -export const zPostAccountEducationResponse = zEducationActivateResponse - export const zGetAccountEducationAutocompleteQuery = z.object({ keywords: z.string(), limit: z.int().optional().default(20), @@ -336,37 +315,37 @@ export const zPostAccountInterfaceLanguageBody = zAccountInterfaceLanguagePayloa /** * Success */ -export const zPostAccountInterfaceLanguageResponse = zAccount +export const zPostAccountInterfaceLanguageResponse = zAccountResponse export const zPostAccountInterfaceThemeBody = zAccountInterfaceThemePayload /** * Success */ -export const zPostAccountInterfaceThemeResponse = zAccount +export const zPostAccountInterfaceThemeResponse = zAccountResponse export const zPostAccountNameBody = zAccountNamePayload /** * Success */ -export const zPostAccountNameResponse = zAccount +export const zPostAccountNameResponse = zAccountResponse export const zPostAccountPasswordBody = zAccountPasswordPayload /** * Success */ -export const zPostAccountPasswordResponse = zAccount +export const zPostAccountPasswordResponse = zAccountResponse /** * Success */ -export const zGetAccountProfileResponse = zAccount +export const zGetAccountProfileResponse = zAccountResponse export const zPostAccountTimezoneBody = zAccountTimezonePayload /** * Success */ -export const zPostAccountTimezoneResponse = zAccount +export const zPostAccountTimezoneResponse = zAccountResponse diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 43119c4f1f4eb7..169669fa15f781 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -48,7 +48,7 @@ export type AgentAppDetailWithSite = { role?: string | null site?: Site | null tags?: Array - tracing?: JsonValue | null + tracing?: unknown | null updated_at?: number | null updated_by?: string | null use_icon_as_answer_icon?: boolean | null @@ -266,15 +266,16 @@ export type AgentLogMessageListResponse = { } export type MessageDetailResponse = { - agent_thoughts?: Array + agent_thoughts: Array annotation?: ConversationAnnotation | null annotation_hit_history?: ConversationAnnotationHitHistory | null - answer_tokens?: number | null + answer: string + answer_tokens: number conversation_id: string created_at?: number | null error?: string | null extra_contents?: Array - feedbacks?: Array + feedbacks: Array from_account_id?: string | null from_end_user_id?: string | null from_source: string @@ -282,14 +283,13 @@ export type MessageDetailResponse = { inputs: { [key: string]: JsonValue } - message?: JsonValue | null - message_files?: Array - message_metadata_dict?: JsonValue | null - message_tokens?: number | null + message: JsonValue + message_files: Array + message_tokens: number + metadata: JsonValue parent_message_id?: string | null - provider_response_latency?: number | null + provider_response_latency: number query: string - re_sign_file_url_answer: string status: string workflow_run_id?: string | null } @@ -405,12 +405,30 @@ export type DeletedTool = { } export type ModelConfig = { - completion_params?: { - [key: string]: unknown - } - mode: LlmMode - name: string - provider: string + agent_mode?: unknown | null + annotation_reply?: unknown | null + chat_prompt_config?: unknown | null + completion_prompt_config?: unknown | null + created_at?: number | null + created_by?: string | null + dataset_configs?: unknown | null + dataset_query_variable?: string | null + external_data_tools?: unknown | null + file_upload?: unknown | null + model?: unknown | null + more_like_this?: unknown | null + opening_statement?: string | null + pre_prompt?: string | null + prompt_type?: string | null + retriever_resource?: unknown | null + sensitive_word_avoidance?: unknown | null + speech_to_text?: unknown | null + suggested_questions?: unknown | null + suggested_questions_after_answer?: unknown | null + text_to_speech?: unknown | null + updated_at?: number | null + updated_by?: string | null + user_input_form?: unknown | null } export type Site = { @@ -436,17 +454,6 @@ export type Tag = { type: string } -export type JsonValue - = | string - | number - | number - | boolean - | { - [key: string]: unknown - } - | Array - | null - export type WorkflowPartial = { created_at?: number | null created_by?: string | null @@ -723,7 +730,6 @@ export type AgentThought = { created_at?: number | null files: Array id: string - message_chain_id?: string | null message_id: string observation?: string | null position: number @@ -743,8 +749,8 @@ export type ConversationAnnotation = { export type ConversationAnnotationHitHistory = { annotation_create_account?: SimpleAccount | null + annotation_id: string created_at?: number | null - id: string } export type HumanInputContent = { @@ -763,6 +769,8 @@ export type Feedback = { rating: string } +export type JsonValue = unknown + export type MessageFile = { belongs_to?: string | null filename: string @@ -865,7 +873,7 @@ export type AgentConfigRevisionResponse = { export type ModelConfigPartial = { created_at?: number | null created_by?: string | null - model?: JsonValue | null + model?: unknown | null pre_prompt?: string | null updated_at?: number | null updated_by?: string | null @@ -879,8 +887,6 @@ export type AgentAppPublishedReferenceResponse = { app_name: string } -export type LlmMode = 'chat' | 'completion' - export type AgentKind = 'dify_agent' export type AgentPublishedReferenceResponse = { @@ -1511,7 +1517,7 @@ export type AgentAppDetailWithSiteWritable = { role?: string | null site?: SiteWritable | null tags?: Array - tracing?: JsonValue | null + tracing?: unknown | null updated_at?: number | null updated_by?: string | null use_icon_as_answer_icon?: boolean | null diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index d7f5681ffc4ba5..e03fd921ee2be1 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -189,6 +189,36 @@ export const zDeletedTool = z.object({ type: z.string(), }) +/** + * ModelConfig + */ +export const zModelConfig = z.object({ + agent_mode: z.unknown().nullish(), + annotation_reply: z.unknown().nullish(), + chat_prompt_config: z.unknown().nullish(), + completion_prompt_config: z.unknown().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + dataset_configs: z.unknown().nullish(), + dataset_query_variable: z.string().nullish(), + external_data_tools: z.unknown().nullish(), + file_upload: z.unknown().nullish(), + model: z.unknown().nullish(), + more_like_this: z.unknown().nullish(), + opening_statement: z.string().nullish(), + pre_prompt: z.string().nullish(), + prompt_type: z.string().nullish(), + retriever_resource: z.unknown().nullish(), + sensitive_word_avoidance: z.unknown().nullish(), + speech_to_text: z.unknown().nullish(), + suggested_questions: z.unknown().nullish(), + suggested_questions_after_answer: z.unknown().nullish(), + text_to_speech: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + user_input_form: z.unknown().nullish(), +}) + /** * Site */ @@ -218,17 +248,6 @@ export const zTag = z.object({ type: z.string(), }) -export const zJsonValue = z - .union([ - z.string(), - z.int(), - z.number(), - z.boolean(), - z.record(z.string(), z.unknown()), - z.array(z.unknown()), - ]) - .nullable() - /** * WorkflowPartial */ @@ -240,6 +259,43 @@ export const zWorkflowPartial = z.object({ updated_by: z.string().nullish(), }) +/** + * AgentAppDetailWithSite + */ +export const zAgentAppDetailWithSite = z.object({ + access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), + api_base_url: z.string().nullish(), + app_id: z.string().nullish(), + bound_agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + debug_conversation_id: z.string().nullish(), + deleted_tools: z.array(zDeletedTool).optional(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + id: z.string(), + maintainer: z.string().nullish(), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zModelConfig.nullish(), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + role: z.string().nullish(), + site: zSite.nullish(), + tags: z.array(zTag).optional(), + tracing: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + /** * AgentConfigSnapshotSummaryResponse */ @@ -562,6 +618,8 @@ export const zAgentLogMessageListResponse = z.object({ total: z.int(), }) +export const zJsonValue = z.unknown() + /** * AgentThought */ @@ -570,7 +628,6 @@ export const zAgentThought = z.object({ created_at: z.int().nullish(), files: z.array(z.string()), id: z.string(), - message_chain_id: z.string().nullish(), message_id: z.string(), observation: z.string().nullish(), position: z.int(), @@ -708,7 +765,7 @@ export const zAgentStatisticSummaryResponse = z.object({ export const zModelConfigPartial = z.object({ created_at: z.int().nullish(), created_by: z.string().nullish(), - model: zJsonValue.nullish(), + model: z.unknown().nullish(), pre_prompt: z.string().nullish(), updated_at: z.int().nullish(), updated_by: z.string().nullish(), @@ -773,60 +830,6 @@ export const zAgentAppPagination = z.object({ total: z.int(), }) -/** - * LLMMode - * - * Enum class for large language model mode. - */ -export const zLlmMode = z.enum(['chat', 'completion']) - -/** - * ModelConfig - */ -export const zModelConfig = z.object({ - completion_params: z.record(z.string(), z.unknown()).optional(), - mode: zLlmMode, - name: z.string(), - provider: z.string(), -}) - -/** - * AgentAppDetailWithSite - */ -export const zAgentAppDetailWithSite = z.object({ - access_mode: z.string().nullish(), - active_config_is_published: z.boolean().optional().default(false), - api_base_url: z.string().nullish(), - app_id: z.string().nullish(), - bound_agent_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - debug_conversation_id: z.string().nullish(), - deleted_tools: z.array(zDeletedTool).optional(), - description: z.string().nullish(), - enable_api: z.boolean(), - enable_site: z.boolean(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: z.string().nullish(), - icon_url: z.string().nullable(), - id: z.string(), - maintainer: z.string().nullish(), - max_active_requests: z.int().nullish(), - mode: z.string(), - model_config: zModelConfig.nullish(), - name: z.string(), - permission_keys: z.array(z.string()).optional(), - role: z.string().nullish(), - site: zSite.nullish(), - tags: z.array(zTag).optional(), - tracing: zJsonValue.nullish(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.nullish(), -}) - /** * AgentKind * @@ -1056,8 +1059,8 @@ export const zConversationAnnotation = z.object({ */ export const zConversationAnnotationHitHistory = z.object({ annotation_create_account: zSimpleAccount.nullish(), + annotation_id: z.string(), created_at: z.int().nullish(), - id: z.string(), }) /** @@ -2032,28 +2035,28 @@ export const zHumanInputContent = z.object({ * MessageDetailResponse */ export const zMessageDetailResponse = z.object({ - agent_thoughts: z.array(zAgentThought).optional(), + agent_thoughts: z.array(zAgentThought), annotation: zConversationAnnotation.nullish(), annotation_hit_history: zConversationAnnotationHitHistory.nullish(), - answer_tokens: z.int().nullish(), + answer: z.string(), + answer_tokens: z.int(), conversation_id: z.string(), created_at: z.int().nullish(), error: z.string().nullish(), extra_contents: z.array(zHumanInputContent).optional(), - feedbacks: z.array(zFeedback).optional(), + feedbacks: z.array(zFeedback), from_account_id: z.string().nullish(), from_end_user_id: z.string().nullish(), from_source: z.string(), id: z.string(), inputs: z.record(z.string(), zJsonValue), - message: zJsonValue.nullish(), - message_files: z.array(zMessageFile).optional(), - message_metadata_dict: zJsonValue.nullish(), - message_tokens: z.int().nullish(), + message: zJsonValue, + message_files: z.array(zMessageFile), + message_tokens: z.int(), + metadata: zJsonValue, parent_message_id: z.string().nullish(), - provider_response_latency: z.number().nullish(), + provider_response_latency: z.number(), query: z.string(), - re_sign_file_url_answer: z.string(), status: z.string(), workflow_run_id: z.string().nullish(), }) @@ -2162,7 +2165,7 @@ export const zAgentAppDetailWithSiteWritable = z.object({ role: z.string().nullish(), site: zSiteWritable.nullish(), tags: z.array(zTag).optional(), - tracing: zJsonValue.nullish(), + tracing: z.unknown().nullish(), updated_at: z.int().nullish(), updated_by: z.string().nullish(), use_icon_as_answer_icon: z.boolean().nullish(), diff --git a/packages/contracts/generated/api/console/all-workspaces/types.gen.ts b/packages/contracts/generated/api/console/all-workspaces/types.gen.ts index 4683b2d9921724..b4c9fa0349aee7 100644 --- a/packages/contracts/generated/api/console/all-workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/all-workspaces/types.gen.ts @@ -4,7 +4,7 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } -export type WorkspaceListResponse = { +export type WorkspacePaginationResponse = { data: Array has_more: boolean limit: number @@ -30,7 +30,7 @@ export type GetAllWorkspacesData = { } export type GetAllWorkspacesResponses = { - 200: WorkspaceListResponse + 200: WorkspacePaginationResponse } export type GetAllWorkspacesResponse = GetAllWorkspacesResponses[keyof GetAllWorkspacesResponses] diff --git a/packages/contracts/generated/api/console/all-workspaces/zod.gen.ts b/packages/contracts/generated/api/console/all-workspaces/zod.gen.ts index f63bd0e396fb1f..c9cdda11681df7 100644 --- a/packages/contracts/generated/api/console/all-workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/all-workspaces/zod.gen.ts @@ -13,9 +13,9 @@ export const zWorkspaceListItemResponse = z.object({ }) /** - * WorkspaceListResponse + * WorkspacePaginationResponse */ -export const zWorkspaceListResponse = z.object({ +export const zWorkspacePaginationResponse = z.object({ data: z.array(zWorkspaceListItemResponse), has_more: z.boolean(), limit: z.int(), @@ -31,4 +31,4 @@ export const zGetAllWorkspacesQuery = z.object({ /** * Success */ -export const zGetAllWorkspacesResponse = zWorkspaceListResponse +export const zGetAllWorkspacesResponse = zWorkspacePaginationResponse diff --git a/packages/contracts/generated/api/console/api-based-extension/zod.gen.ts b/packages/contracts/generated/api/console/api-based-extension/zod.gen.ts index 468e9d09efc274..745f6ddb10791c 100644 --- a/packages/contracts/generated/api/console/api-based-extension/zod.gen.ts +++ b/packages/contracts/generated/api/console/api-based-extension/zod.gen.ts @@ -22,6 +22,9 @@ export const zApiBasedExtensionResponse = z.object({ name: z.string(), }) +/** + * APIBasedExtensionListResponse + */ export const zApiBasedExtensionListResponse = z.array(zApiBasedExtensionResponse) /** diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index ea72df2845852e..41541ce8349db2 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -173,11 +173,6 @@ import { zGetAppsByAppIdWorkflowRunsPath, zGetAppsByAppIdWorkflowRunsQuery, zGetAppsByAppIdWorkflowRunsResponse, - zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath, - zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeQuery, - zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse, - zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsPath, - zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse, zGetAppsByAppIdWorkflowsDraftConversationVariablesPath, zGetAppsByAppIdWorkflowsDraftConversationVariablesResponse, zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesPath, @@ -258,18 +253,6 @@ import { zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewBody, zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewPath, zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse, - zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunBody, - zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunPath, - zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse, - zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunBody, - zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunPath, - zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponse, - zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunBody, - zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunPath, - zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponse, - zPostAppsByAppIdAdvancedChatWorkflowsDraftRunBody, - zPostAppsByAppIdAdvancedChatWorkflowsDraftRunPath, - zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse, zPostAppsByAppIdAgentFilesBody, zPostAppsByAppIdAgentFilesPath, zPostAppsByAppIdAgentFilesQuery, @@ -302,11 +285,8 @@ import { zPostAppsByAppIdAudioToTextResponse, zPostAppsByAppIdChatMessagesByTaskIdStopPath, zPostAppsByAppIdChatMessagesByTaskIdStopResponse, - zPostAppsByAppIdCompletionMessagesBody, zPostAppsByAppIdCompletionMessagesByTaskIdStopPath, zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse, - zPostAppsByAppIdCompletionMessagesPath, - zPostAppsByAppIdCompletionMessagesResponse, zPostAppsByAppIdConvertToWorkflowBody, zPostAppsByAppIdConvertToWorkflowPath, zPostAppsByAppIdConvertToWorkflowResponse, @@ -340,9 +320,6 @@ import { zPostAppsByAppIdSiteResponse, zPostAppsByAppIdStarPath, zPostAppsByAppIdStarResponse, - zPostAppsByAppIdTextToAudioBody, - zPostAppsByAppIdTextToAudioPath, - zPostAppsByAppIdTextToAudioResponse, zPostAppsByAppIdTraceBody, zPostAppsByAppIdTraceConfigBody, zPostAppsByAppIdTraceConfigPath, @@ -383,15 +360,6 @@ import { zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewBody, zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewPath, zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse, - zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunBody, - zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunPath, - zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse, - zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunBody, - zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunPath, - zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse, - zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody, - zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath, - zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse, @@ -411,15 +379,6 @@ import { zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse, zPostAppsByAppIdWorkflowsDraftPath, zPostAppsByAppIdWorkflowsDraftResponse, - zPostAppsByAppIdWorkflowsDraftRunBody, - zPostAppsByAppIdWorkflowsDraftRunPath, - zPostAppsByAppIdWorkflowsDraftRunResponse, - zPostAppsByAppIdWorkflowsDraftTriggerRunAllBody, - zPostAppsByAppIdWorkflowsDraftTriggerRunAllPath, - zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse, - zPostAppsByAppIdWorkflowsDraftTriggerRunBody, - zPostAppsByAppIdWorkflowsDraftTriggerRunPath, - zPostAppsByAppIdWorkflowsDraftTriggerRunResponse, zPostAppsByAppIdWorkflowsPublishBody, zPostAppsByAppIdWorkflowsPublishPath, zPostAppsByAppIdWorkflowsPublishResponse, @@ -630,36 +589,8 @@ export const preview = { post: post4, } -/** - * Submit human input form preview - * - * Submit human input form preview for advanced chat workflow - */ -export const post5 = oc - .route({ - description: 'Submit human input form preview for advanced chat workflow', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRun', - path: '/apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/run', - summary: 'Submit human input form preview', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunBody, - params: zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunPath, - }), - ) - .output(zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) - -export const run = { - post: post5, -} - export const form = { preview, - run, } export const byNodeId = { @@ -674,116 +605,8 @@ export const humanInput = { nodes, } -/** - * Run draft workflow iteration node - * - * Run draft workflow iteration node for advanced chat - */ -export const post6 = oc - .route({ - description: 'Run draft workflow iteration node for advanced chat', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRun', - path: '/apps/{app_id}/advanced-chat/workflows/draft/iteration/nodes/{node_id}/run', - summary: 'Run draft workflow iteration node', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunBody, - params: zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunPath, - }), - ) - .output(zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponse) - -export const run2 = { - post: post6, -} - -export const byNodeId2 = { - run: run2, -} - -export const nodes2 = { - byNodeId: byNodeId2, -} - -export const iteration = { - nodes: nodes2, -} - -/** - * Run draft workflow loop node - * - * Run draft workflow loop node for advanced chat - */ -export const post7 = oc - .route({ - description: 'Run draft workflow loop node for advanced chat', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRun', - path: '/apps/{app_id}/advanced-chat/workflows/draft/loop/nodes/{node_id}/run', - summary: 'Run draft workflow loop node', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunBody, - params: zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunPath, - }), - ) - .output(zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponse) - -export const run3 = { - post: post7, -} - -export const byNodeId3 = { - run: run3, -} - -export const nodes3 = { - byNodeId: byNodeId3, -} - -export const loop = { - nodes: nodes3, -} - -/** - * Run draft workflow - * - * Run draft workflow for advanced chat application - */ -export const post8 = oc - .route({ - description: 'Run draft workflow for advanced chat application', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftRun', - path: '/apps/{app_id}/advanced-chat/workflows/draft/run', - summary: 'Run draft workflow', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdAdvancedChatWorkflowsDraftRunBody, - params: zPostAppsByAppIdAdvancedChatWorkflowsDraftRunPath, - }), - ) - .output(zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse) - -export const run4 = { - post: post8, -} - export const draft = { humanInput, - iteration, - loop, - run: run4, } export const workflows2 = { @@ -953,7 +776,7 @@ export const delete_ = oc * * Commit an uploaded file into the agent drive under files/ (ENG-625 D3) */ -export const post9 = oc +export const post5 = oc .route({ description: 'Commit an uploaded file into the agent drive under files/ (ENG-625 D3)', inputStructure: 'detailed', @@ -975,7 +798,7 @@ export const post9 = oc export const files2 = { delete: delete_, - post: post9, + post: post5, } /** @@ -1005,7 +828,7 @@ export const logs = { * * Upload + standardize a Skill into the agent drive */ -export const post10 = oc +export const post6 = oc .route({ description: 'Upload + standardize a Skill into the agent drive', inputStructure: 'detailed', @@ -1026,7 +849,7 @@ export const post10 = oc .output(zPostAppsByAppIdAgentSkillsUploadResponse) export const upload = { - post: post10, + post: post6, } /** @@ -1035,7 +858,7 @@ export const upload = { * Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371) * Saving still goes through composer validation. */ -export const post11 = oc +export const post7 = oc .route({ description: 'Infer CLI tool + ENV suggestions from a standardized skill\'s SKILL.md (draft only, ENG-371)\nSaving still goes through composer validation.', @@ -1055,7 +878,7 @@ export const post11 = oc .output(zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse) export const inferTools = { - post: post11, + post: post7, } /** @@ -1121,7 +944,7 @@ export const status = { /** * Enable or disable annotation reply for an app */ -export const post12 = oc +export const post8 = oc .route({ description: 'Enable or disable annotation reply for an app', inputStructure: 'detailed', @@ -1139,7 +962,7 @@ export const post12 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post12, + post: post8, status, } @@ -1169,7 +992,7 @@ export const annotationSetting = { /** * Update annotation settings for an app */ -export const post13 = oc +export const post9 = oc .route({ description: 'Update annotation settings for an app', inputStructure: 'detailed', @@ -1187,7 +1010,7 @@ export const post13 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post13, + post: post9, } export const annotationSettings = { @@ -1197,7 +1020,7 @@ export const annotationSettings = { /** * Batch import annotations from CSV file with rate limiting and security checks */ -export const post14 = oc +export const post10 = oc .route({ description: 'Batch import annotations from CSV file with rate limiting and security checks', inputStructure: 'detailed', @@ -1210,7 +1033,7 @@ export const post14 = oc .output(zPostAppsByAppIdAnnotationsBatchImportResponse) export const batchImport = { - post: post14, + post: post10, } /** @@ -1313,7 +1136,7 @@ export const delete3 = oc /** * Update or delete an annotation */ -export const post15 = oc +export const post11 = oc .route({ description: 'Update or delete an annotation', inputStructure: 'detailed', @@ -1332,7 +1155,7 @@ export const post15 = oc export const byAnnotationId = { delete: delete3, - post: post15, + post: post11, hitHistories, } @@ -1371,7 +1194,7 @@ export const get17 = oc /** * Create a new annotation for an app */ -export const post16 = oc +export const post12 = oc .route({ description: 'Create a new annotation for an app', inputStructure: 'detailed', @@ -1389,7 +1212,7 @@ export const post16 = oc export const annotations = { delete: delete4, get: get17, - post: post16, + post: post12, batchImport, batchImportStatus, count: count2, @@ -1400,7 +1223,7 @@ export const annotations = { /** * Enable or disable app API */ -export const post17 = oc +export const post13 = oc .route({ description: 'Enable or disable app API', inputStructure: 'detailed', @@ -1413,13 +1236,13 @@ export const post17 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post17, + post: post13, } /** * Transcript audio to text for chat messages */ -export const post18 = oc +export const post14 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1432,7 +1255,7 @@ export const post18 = oc .output(zPostAppsByAppIdAudioToTextResponse) export const audioToText = { - post: post18, + post: post14, } /** @@ -1522,7 +1345,7 @@ export const byMessageId = { /** * Stop a running chat message generation */ -export const post19 = oc +export const post15 = oc .route({ description: 'Stop a running chat message generation', inputStructure: 'detailed', @@ -1535,7 +1358,7 @@ export const post19 = oc .output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post19, + post: post15, } export const byTaskId = { @@ -1629,7 +1452,7 @@ export const completionConversations = { /** * Stop a running completion message generation */ -export const post20 = oc +export const post16 = oc .route({ description: 'Stop a running completion message generation', inputStructure: 'detailed', @@ -1642,35 +1465,14 @@ export const post20 = oc .output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post20, + post: post16, } export const byTaskId2 = { stop: stop2, } -/** - * Generate completion message for debugging - */ -export const post21 = oc - .route({ - description: 'Generate completion message for debugging', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdCompletionMessages', - path: '/apps/{app_id}/completion-messages', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdCompletionMessagesBody, - params: zPostAppsByAppIdCompletionMessagesPath, - }), - ) - .output(zPostAppsByAppIdCompletionMessagesResponse) - export const completionMessages = { - post: post21, byTaskId: byTaskId2, } @@ -1705,7 +1507,7 @@ export const conversationVariables = { * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App */ -export const post22 = oc +export const post17 = oc .route({ description: 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', @@ -1725,7 +1527,7 @@ export const post22 = oc .output(zPostAppsByAppIdConvertToWorkflowResponse) export const convertToWorkflow = { - post: post22, + post: post17, } /** @@ -1733,7 +1535,7 @@ export const convertToWorkflow = { * * Create a copy of an existing application */ -export const post23 = oc +export const post18 = oc .route({ description: 'Create a copy of an existing application', inputStructure: 'detailed', @@ -1748,7 +1550,7 @@ export const post23 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post23, + post: post18, } /** @@ -1802,7 +1604,7 @@ export const export3 = { /** * Create or update message feedback (like/dislike) */ -export const post24 = oc +export const post19 = oc .route({ description: 'Create or update message feedback (like/dislike)', inputStructure: 'detailed', @@ -1815,14 +1617,14 @@ export const post24 = oc .output(zPostAppsByAppIdFeedbacksResponse) export const feedbacks = { - post: post24, + post: post19, export: export3, } /** * Update application icon */ -export const post25 = oc +export const post20 = oc .route({ description: 'Update application icon', inputStructure: 'detailed', @@ -1835,7 +1637,7 @@ export const post25 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post25, + post: post20, } /** @@ -1866,7 +1668,7 @@ export const messages = { * * Update application model configuration */ -export const post26 = oc +export const post21 = oc .route({ description: 'Update application model configuration', inputStructure: 'detailed', @@ -1882,13 +1684,13 @@ export const post26 = oc .output(zPostAppsByAppIdModelConfigResponse) export const modelConfig = { - post: post26, + post: post21, } /** * Check if app name is available */ -export const post27 = oc +export const post22 = oc .route({ description: 'Check if app name is available', inputStructure: 'detailed', @@ -1901,13 +1703,13 @@ export const post27 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post27, + post: post22, } /** * Publish app to Creators Platform */ -export const post28 = oc +export const post23 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1920,7 +1722,7 @@ export const post28 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post28, + post: post23, } /** @@ -1941,7 +1743,7 @@ export const get28 = oc /** * Create MCP server configuration for an application */ -export const post29 = oc +export const post24 = oc .route({ description: 'Create MCP server configuration for an application', inputStructure: 'detailed', @@ -1971,14 +1773,14 @@ export const put = oc export const server = { get: get28, - post: post29, + post: post24, put, } /** * Reset access token for application site */ -export const post30 = oc +export const post25 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -1991,13 +1793,13 @@ export const post30 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post30, + post: post25, } /** * Update application site configuration */ -export const post31 = oc +export const post26 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -2010,14 +1812,14 @@ export const post31 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post31, + post: post26, accessTokenReset, } /** * Enable or disable app site */ -export const post32 = oc +export const post27 = oc .route({ description: 'Enable or disable app site', inputStructure: 'detailed', @@ -2030,7 +1832,7 @@ export const post32 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post32, + post: post27, } /** @@ -2051,7 +1853,7 @@ export const delete7 = oc /** * Star an application for the current account */ -export const post33 = oc +export const post28 = oc .route({ description: 'Star an application for the current account', inputStructure: 'detailed', @@ -2065,7 +1867,7 @@ export const post33 = oc export const star = { delete: delete7, - post: post33, + post: post28, } /** @@ -2295,25 +2097,7 @@ export const voices = { get: get37, } -/** - * Convert text to speech for chat messages - */ -export const post34 = oc - .route({ - description: 'Convert text to speech for chat messages', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdTextToAudio', - path: '/apps/{app_id}/text-to-audio', - tags: ['console'], - }) - .input( - z.object({ body: zPostAppsByAppIdTextToAudioBody, params: zPostAppsByAppIdTextToAudioPath }), - ) - .output(zPostAppsByAppIdTextToAudioResponse) - export const textToAudio = { - post: post34, voices, } @@ -2338,7 +2122,7 @@ export const get38 = oc /** * Update app tracing configuration */ -export const post35 = oc +export const post29 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2352,7 +2136,7 @@ export const post35 = oc export const trace = { get: get38, - post: post35, + post: post29, } /** @@ -2421,7 +2205,7 @@ export const patch = oc * * Create a new tracing configuration for an application */ -export const post36 = oc +export const post30 = oc .route({ description: 'Create a new tracing configuration for an application', inputStructure: 'detailed', @@ -2441,13 +2225,13 @@ export const traceConfig = { delete: delete8, get: get39, patch, - post: post36, + post: post30, } /** * Update app trigger (enable/disable) */ -export const post37 = oc +export const post31 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2465,7 +2249,7 @@ export const post37 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post37, + post: post31, } /** @@ -2573,7 +2357,7 @@ export const count3 = { * * Stop running workflow task */ -export const post38 = oc +export const post32 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2587,7 +2371,7 @@ export const post38 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post38, + post: post32, } export const byTaskId3 = { @@ -2690,7 +2474,7 @@ export const read = { /** * Upload one workflow Agent sandbox file as a Dify ToolFile mapping */ -export const post39 = oc +export const post33 = oc .route({ description: 'Upload one workflow Agent sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', @@ -2708,7 +2492,7 @@ export const post39 = oc .output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse) export const upload2 = { - post: post39, + post: post33, } /** @@ -2742,12 +2526,12 @@ export const sandbox = { files: files3, } -export const byNodeId4 = { +export const byNodeId2 = { sandbox, } export const agentNodes = { - byNodeId: byNodeId4, + byNodeId: byNodeId2, } export const byWorkflowRunId = { @@ -2859,7 +2643,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post40 = oc +export const post34 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -2879,7 +2663,7 @@ export const post40 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post40, + post: post34, byReplyId, } @@ -2888,7 +2672,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post41 = oc +export const post35 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -2902,7 +2686,7 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post41, + post: post35, } /** @@ -2996,7 +2780,7 @@ export const get52 = oc * * Create a new workflow comment */ -export const post42 = oc +export const post36 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -3017,7 +2801,7 @@ export const post42 = oc export const comments = { get: get52, - post: post42, + post: post36, mentionUsers, byCommentId, } @@ -3131,65 +2915,15 @@ export const workflow = { } /** - * Get default block config - * - * Get default block configuration by type + * Get conversation variables for workflow */ export const get57 = oc .route({ - description: 'Get default block configuration by type', + description: 'Get conversation variables for workflow', inputStructure: 'detailed', method: 'GET', - operationId: 'getAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockType', - path: '/apps/{app_id}/workflows/default-workflow-block-configs/{block_type}', - summary: 'Get default block config', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath, - query: zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeQuery.optional(), - }), - ) - .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) - -export const byBlockType = { - get: get57, -} - -/** - * Get default block config - * - * Get default block configurations for workflow - */ -export const get58 = oc - .route({ - description: 'Get default block configurations for workflow', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdWorkflowsDefaultWorkflowBlockConfigs', - path: '/apps/{app_id}/workflows/default-workflow-block-configs', - summary: 'Get default block config', - tags: ['console'], - }) - .input(z.object({ params: zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsPath })) - .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) - -export const defaultWorkflowBlockConfigs = { - get: get58, - byBlockType, -} - -/** - * Get conversation variables for workflow - */ -export const get59 = oc - .route({ - description: 'Get conversation variables for workflow', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdWorkflowsDraftConversationVariables', - path: '/apps/{app_id}/workflows/draft/conversation-variables', + operationId: 'getAppsByAppIdWorkflowsDraftConversationVariables', + path: '/apps/{app_id}/workflows/draft/conversation-variables', tags: ['console'], }) .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftConversationVariablesPath })) @@ -3198,7 +2932,7 @@ export const get59 = oc /** * Update conversation variables for workflow draft */ -export const post43 = oc +export const post37 = oc .route({ description: 'Update conversation variables for workflow draft', inputStructure: 'detailed', @@ -3216,8 +2950,8 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get59, - post: post43, + get: get57, + post: post37, } /** @@ -3225,7 +2959,7 @@ export const conversationVariables2 = { * * Get environment variables for workflow */ -export const get60 = oc +export const get58 = oc .route({ description: 'Get environment variables for workflow', inputStructure: 'detailed', @@ -3241,7 +2975,7 @@ export const get60 = oc /** * Update environment variables for workflow draft */ -export const post44 = oc +export const post38 = oc .route({ description: 'Update environment variables for workflow draft', inputStructure: 'detailed', @@ -3259,14 +2993,14 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get60, - post: post44, + get: get58, + post: post38, } /** * Update draft workflow features */ -export const post45 = oc +export const post39 = oc .route({ description: 'Update draft workflow features', inputStructure: 'detailed', @@ -3284,7 +3018,7 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post45, + post: post39, } /** @@ -3292,7 +3026,7 @@ export const features = { * * Test human input delivery for workflow */ -export const post46 = oc +export const post40 = oc .route({ description: 'Test human input delivery for workflow', inputStructure: 'detailed', @@ -3311,7 +3045,7 @@ export const post46 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post46, + post: post40, } /** @@ -3319,7 +3053,7 @@ export const deliveryTest = { * * Get human input form preview for workflow */ -export const post47 = oc +export const post41 = oc .route({ description: 'Get human input form preview for workflow', inputStructure: 'detailed', @@ -3338,133 +3072,27 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) export const preview3 = { - post: post47, -} - -/** - * Submit human input form preview - * - * Submit human input form preview for workflow - */ -export const post48 = oc - .route({ - description: 'Submit human input form preview for workflow', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRun', - path: '/apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/run', - summary: 'Submit human input form preview', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunBody, - params: zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunPath, - }), - ) - .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) - -export const run5 = { - post: post48, + post: post41, } export const form2 = { preview: preview3, - run: run5, } -export const byNodeId5 = { +export const byNodeId3 = { deliveryTest, form: form2, } -export const nodes4 = { - byNodeId: byNodeId5, +export const nodes2 = { + byNodeId: byNodeId3, } export const humanInput2 = { - nodes: nodes4, -} - -/** - * Run draft workflow iteration node - * - * Run draft workflow iteration node - */ -export const post49 = oc - .route({ - description: 'Run draft workflow iteration node', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRun', - path: '/apps/{app_id}/workflows/draft/iteration/nodes/{node_id}/run', - summary: 'Run draft workflow iteration node', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunBody, - params: zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunPath, - }), - ) - .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) - -export const run6 = { - post: post49, -} - -export const byNodeId6 = { - run: run6, -} - -export const nodes5 = { - byNodeId: byNodeId6, -} - -export const iteration2 = { - nodes: nodes5, -} - -/** - * Run draft workflow loop node - * - * Run draft workflow loop node - */ -export const post50 = oc - .route({ - description: 'Run draft workflow loop node', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRun', - path: '/apps/{app_id}/workflows/draft/loop/nodes/{node_id}/run', - summary: 'Run draft workflow loop node', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody, - params: zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath, - }), - ) - .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) - -export const run7 = { - post: post50, -} - -export const byNodeId7 = { - run: run7, -} - -export const nodes6 = { - byNodeId: byNodeId7, -} - -export const loop2 = { - nodes: nodes6, + nodes: nodes2, } -export const get61 = oc +export const get59 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3478,10 +3106,10 @@ export const get61 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) export const candidates = { - get: get61, + get: get59, } -export const post51 = oc +export const post42 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3498,10 +3126,10 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse) export const copyFromRoster = { - post: post51, + post: post42, } -export const post52 = oc +export const post43 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3518,10 +3146,10 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post52, + post: post43, } -export const post53 = oc +export const post44 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3538,10 +3166,10 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post53, + post: post44, } -export const post54 = oc +export const post45 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3558,10 +3186,10 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate = { - post: post54, + post: post45, } -export const get62 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3589,7 +3217,7 @@ export const put4 = oc .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) export const agentComposer = { - get: get62, + get: get60, put: put4, candidates, copyFromRoster, @@ -3601,7 +3229,7 @@ export const agentComposer = { /** * Get last run result for draft workflow node */ -export const get63 = oc +export const get61 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3614,7 +3242,7 @@ export const get63 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get63, + get: get61, } /** @@ -3622,7 +3250,7 @@ export const lastRun = { * * Run draft workflow node */ -export const post55 = oc +export const post46 = oc .route({ description: 'Run draft workflow node', inputStructure: 'detailed', @@ -3640,8 +3268,8 @@ export const post55 = oc ) .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) -export const run8 = { - post: post55, +export const run = { + post: post46, } /** @@ -3649,7 +3277,7 @@ export const run8 = { * * Poll for trigger events and execute single node when event arrives */ -export const post56 = oc +export const post47 = oc .route({ description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', @@ -3662,12 +3290,12 @@ export const post56 = oc .input(z.object({ params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunPath })) .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) -export const run9 = { - post: post56, +export const run2 = { + post: post47, } export const trigger = { - run: run9, + run: run2, } /** @@ -3689,7 +3317,7 @@ export const delete11 = oc /** * Get variables for a specific node */ -export const get64 = oc +export const get62 = oc .route({ description: 'Get variables for a specific node', inputStructure: 'detailed', @@ -3703,52 +3331,25 @@ export const get64 = oc export const variables = { delete: delete11, - get: get64, + get: get62, } -export const byNodeId8 = { +export const byNodeId4 = { agentComposer, lastRun, - run: run8, + run, trigger, variables, } -export const nodes7 = { - byNodeId: byNodeId8, -} - -/** - * Run draft workflow - * - * Run draft workflow - */ -export const post57 = oc - .route({ - description: 'Run draft workflow', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdWorkflowsDraftRun', - path: '/apps/{app_id}/workflows/draft/run', - summary: 'Run draft workflow', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdWorkflowsDraftRunBody, - params: zPostAppsByAppIdWorkflowsDraftRunPath, - }), - ) - .output(zPostAppsByAppIdWorkflowsDraftRunResponse) - -export const run10 = { - post: post57, +export const nodes3 = { + byNodeId: byNodeId4, } /** * Server-Sent Events stream of inspector deltas for a draft workflow run. */ -export const get65 = oc +export const get63 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a draft workflow run.', inputStructure: 'detailed', @@ -3761,13 +3362,13 @@ export const get65 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get65, + get: get63, } /** * Full value for one declared output, including signed download URL for files. */ -export const get66 = oc +export const get64 = oc .route({ description: 'Full value for one declared output, including signed download URL for files.', inputStructure: 'detailed', @@ -3784,7 +3385,7 @@ export const get66 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) export const preview4 = { - get: get66, + get: get64, } export const byOutputName = { @@ -3794,7 +3395,7 @@ export const byOutputName = { /** * One node's declared outputs for a draft workflow run. */ -export const get67 = oc +export const get65 = oc .route({ description: 'One node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -3806,15 +3407,15 @@ export const get67 = oc .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdPath })) .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) -export const byNodeId9 = { - get: get67, +export const byNodeId5 = { + get: get65, byOutputName, } /** * Snapshot of every node's declared outputs for a draft workflow run. */ -export const get68 = oc +export const get66 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -3827,9 +3428,9 @@ export const get68 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get68, + get: get66, events, - byNodeId: byNodeId9, + byNodeId: byNodeId5, } export const byRunId2 = { @@ -3843,7 +3444,7 @@ export const runs = { /** * Get system variables for workflow */ -export const get69 = oc +export const get67 = oc .route({ description: 'Get system variables for workflow', inputStructure: 'detailed', @@ -3856,66 +3457,7 @@ export const get69 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get69, -} - -/** - * Poll for trigger events and execute full workflow when event arrives - * - * Poll for trigger events and execute full workflow when event arrives - */ -export const post58 = oc - .route({ - description: 'Poll for trigger events and execute full workflow when event arrives', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdWorkflowsDraftTriggerRun', - path: '/apps/{app_id}/workflows/draft/trigger/run', - summary: 'Poll for trigger events and execute full workflow when event arrives', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdWorkflowsDraftTriggerRunBody, - params: zPostAppsByAppIdWorkflowsDraftTriggerRunPath, - }), - ) - .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) - -export const run11 = { - post: post58, -} - -/** - * Full workflow debug when the start node is a trigger - * - * Full workflow debug when the start node is a trigger - */ -export const post59 = oc - .route({ - description: 'Full workflow debug when the start node is a trigger', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdWorkflowsDraftTriggerRunAll', - path: '/apps/{app_id}/workflows/draft/trigger/run-all', - summary: 'Full workflow debug when the start node is a trigger', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdWorkflowsDraftTriggerRunAllBody, - params: zPostAppsByAppIdWorkflowsDraftTriggerRunAllPath, - }), - ) - .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) - -export const runAll = { - post: post59, -} - -export const trigger2 = { - run: run11, - runAll, + get: get67, } /** @@ -3956,7 +3498,7 @@ export const delete12 = oc /** * Get a specific workflow variable */ -export const get70 = oc +export const get68 = oc .route({ description: 'Get a specific workflow variable', inputStructure: 'detailed', @@ -3990,7 +3532,7 @@ export const patch2 = oc export const byVariableId = { delete: delete12, - get: get70, + get: get68, patch: patch2, reset, } @@ -4016,7 +3558,7 @@ export const delete13 = oc * * Get draft workflow variables */ -export const get71 = oc +export const get69 = oc .route({ description: 'Get draft workflow variables', inputStructure: 'detailed', @@ -4036,7 +3578,7 @@ export const get71 = oc export const variables2 = { delete: delete13, - get: get71, + get: get69, byVariableId, } @@ -4045,7 +3587,7 @@ export const variables2 = { * * Get draft workflow for an application */ -export const get72 = oc +export const get70 = oc .route({ description: 'Get draft workflow for an application', inputStructure: 'detailed', @@ -4063,7 +3605,7 @@ export const get72 = oc * * Sync draft workflow configuration */ -export const post60 = oc +export const post48 = oc .route({ description: 'Sync draft workflow configuration', inputStructure: 'detailed', @@ -4082,19 +3624,15 @@ export const post60 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get72, - post: post60, + get: get70, + post: post48, conversationVariables: conversationVariables2, environmentVariables, features, humanInput: humanInput2, - iteration: iteration2, - loop: loop2, - nodes: nodes7, - run: run10, + nodes: nodes3, runs, systemVariables, - trigger: trigger2, variables: variables2, } @@ -4103,7 +3641,7 @@ export const draft2 = { * * Get published workflow for an application */ -export const get73 = oc +export const get71 = oc .route({ description: 'Get published workflow for an application', inputStructure: 'detailed', @@ -4119,7 +3657,7 @@ export const get73 = oc /** * Publish workflow */ -export const post61 = oc +export const post49 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4137,14 +3675,14 @@ export const post61 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get73, - post: post61, + get: get71, + post: post49, } /** * Server-Sent Events stream of inspector deltas for a published workflow run. */ -export const get74 = oc +export const get72 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a published workflow run.', inputStructure: 'detailed', @@ -4157,13 +3695,13 @@ export const get74 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get74, + get: get72, } /** * Full value for one declared output of a published run. */ -export const get75 = oc +export const get73 = oc .route({ description: 'Full value for one declared output of a published run.', inputStructure: 'detailed', @@ -4184,7 +3722,7 @@ export const get75 = oc ) export const preview5 = { - get: get75, + get: get73, } export const byOutputName2 = { @@ -4194,7 +3732,7 @@ export const byOutputName2 = { /** * One node's declared outputs for a published workflow run. */ -export const get76 = oc +export const get74 = oc .route({ description: 'One node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4206,15 +3744,15 @@ export const get76 = oc .input(z.object({ params: zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdPath })) .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) -export const byNodeId10 = { - get: get76, +export const byNodeId6 = { + get: get74, byOutputName: byOutputName2, } /** * Snapshot of every node's declared outputs for a published workflow run. */ -export const get77 = oc +export const get75 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4227,9 +3765,9 @@ export const get77 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get77, + get: get75, events: events2, - byNodeId: byNodeId10, + byNodeId: byNodeId6, } export const byRunId3 = { @@ -4247,7 +3785,7 @@ export const published = { /** * Get webhook trigger for a node */ -export const get78 = oc +export const get76 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4265,7 +3803,7 @@ export const get78 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get78, + get: get76, } export const triggers2 = { @@ -4275,7 +3813,7 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow */ -export const post62 = oc +export const post50 = oc .route({ description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', @@ -4288,7 +3826,7 @@ export const post62 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post62, + post: post50, } /** @@ -4341,7 +3879,7 @@ export const byWorkflowId = { * * Get all published workflows for an application */ -export const get79 = oc +export const get77 = oc .route({ description: 'Get all published workflows for an application', inputStructure: 'detailed', @@ -4360,8 +3898,7 @@ export const get79 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get79, - defaultWorkflowBlockConfigs, + get: get77, draft: draft2, publish, published, @@ -4393,7 +3930,7 @@ export const delete15 = oc * * Get application details */ -export const get80 = oc +export const get78 = oc .route({ description: 'Get application details', inputStructure: 'detailed', @@ -4426,7 +3963,7 @@ export const put6 = oc export const byAppId2 = { delete: delete15, - get: get80, + get: get78, put: put6, advancedChat, agent, @@ -4495,7 +4032,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get81 = oc +export const get79 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4513,7 +4050,7 @@ export const get81 = oc * * Create a new API key for an app */ -export const post63 = oc +export const post51 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4528,8 +4065,8 @@ export const post63 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get81, - post: post63, + get: get79, + post: post51, byApiKeyId, } @@ -4540,7 +4077,7 @@ export const byResourceId = { /** * Refresh MCP server configuration and regenerate server code */ -export const get82 = oc +export const get80 = oc .route({ description: 'Refresh MCP server configuration and regenerate server code', inputStructure: 'detailed', @@ -4553,7 +4090,7 @@ export const get82 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get82, + get: get80, } export const server2 = { @@ -4569,7 +4106,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get83 = oc +export const get81 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4587,7 +4124,7 @@ export const get83 = oc * * Create a new application */ -export const post64 = oc +export const post52 = oc .route({ description: 'Create a new application', inputStructure: 'detailed', @@ -4602,8 +4139,8 @@ export const post64 = oc .output(zPostAppsResponse) export const apps = { - get: get83, - post: post64, + get: get81, + post: post52, imports, starred, workflows, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 9e79518f3cd3ae..2da4f8d94f5939 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -45,7 +45,7 @@ export type AppDetailWithSite = { permission_keys?: Array site?: Site | null tags?: Array - tracing?: JsonValue | null + tracing?: unknown | null updated_at?: number | null updated_by?: string | null use_icon_as_answer_icon?: boolean | null @@ -119,10 +119,9 @@ export type HumanInputFormPreviewPayload = { } export type HumanInputFormPreviewResponse = { - actions?: Array<{ - [key: string]: unknown - }> - display_in_ui?: boolean | null + TYPE?: 'human_input_required' + actions?: Array + display_in_ui?: boolean expiration_time?: number | null form_content: string form_id: string @@ -137,46 +136,6 @@ export type HumanInputFormPreviewResponse = { } } -export type HumanInputFormSubmitPayload = { - action: string - form_inputs: { - [key: string]: unknown - } - inputs: { - [key: string]: unknown - } -} - -export type HumanInputFormSubmitResponse = { - [key: string]: unknown -} - -export type IterationNodeRunPayload = { - inputs?: { - [key: string]: unknown - } | null -} - -export type GeneratedAppResponse = JsonValue - -export type LoopNodeRunPayload = { - inputs?: { - [key: string]: unknown - } | null -} - -export type AdvancedChatWorkflowRunPayload = { - conversation_id?: string | null - files?: Array<{ - [key: string]: unknown - }> | null - inputs?: { - [key: string]: unknown - } | null - parent_message_id?: string | null - query?: string -} - export type AgentDriveListResponse = { items?: Array } @@ -253,14 +212,18 @@ export type AnnotationReplyPayload = { } export type AnnotationJobStatusResponse = { - error_msg?: string | null - job_id?: string | null - job_status?: string | null - record_count?: number | null + job_id: string + job_status: 'completed' | 'error' | 'processing' | 'waiting' | string +} + +export type AnnotationJobStatusDetailResponse = { + error_msg?: string + job_id: string + job_status: 'completed' | 'error' | 'processing' | 'waiting' | string } export type AnnotationSettingResponse = { - embedding_model?: AnnotationEmbeddingModelResponse | null + embedding_model?: AnnotationSettingEmbeddingModelResponse | null enabled: boolean id?: string | null score_threshold?: number | null @@ -296,6 +259,13 @@ export type Annotation = { question?: string | null } +export type AnnotationBatchImportResponse = { + error_msg?: string | null + job_id?: string | null + job_status?: string | null + record_count?: number | null +} + export type AnnotationCountResponse = { count: number } @@ -341,7 +311,7 @@ export type AppDetail = { name: string permission_keys?: Array tags?: Array - tracing?: JsonValue | null + tracing?: unknown | null updated_at?: number | null updated_by?: string | null use_icon_as_answer_icon?: boolean | null @@ -353,10 +323,10 @@ export type AudioTranscriptResponse = { } export type ConversationWithSummaryPagination = { - has_next: boolean - items: Array + data: Array + has_more: boolean + limit: number page: number - per_page: number total: number } @@ -391,37 +361,24 @@ export type SimpleResultResponse = { } export type ConversationPagination = { - has_next: boolean - items: Array + data: Array + has_more: boolean + limit: number page: number - per_page: number total: number } export type ConversationMessageDetail = { created_at?: number | null - first_message?: MessageDetail | null from_account_id?: string | null from_end_user_id?: string | null from_source: string id: string + message?: MessageDetail | null model_config?: ModelConfig | null status: string } -export type CompletionMessagePayload = { - files?: Array | null - inputs: { - [key: string]: unknown - } - model_config?: { - [key: string]: unknown - } - query?: string - response_mode?: 'blocking' | 'streaming' - retriever_from?: string -} - export type PaginatedConversationVariableResponse = { data: Array has_more: boolean @@ -450,6 +407,16 @@ export type CopyAppPayload = { name?: string | null } +export type AppImportResponse = { + app_id?: string | null + app_mode?: string | null + current_dsl_version: string + error?: string + id: string + imported_dsl_version?: string + status: ImportStatus +} + export type AppExportResponse = { data: string } @@ -469,15 +436,16 @@ export type AppIconPayload = { } export type MessageDetailResponse = { - agent_thoughts?: Array + agent_thoughts: Array annotation?: ConversationAnnotation | null annotation_hit_history?: ConversationAnnotationHitHistory | null - answer_tokens?: number | null + answer: string + answer_tokens: number conversation_id: string created_at?: number | null error?: string | null extra_contents?: Array - feedbacks?: Array + feedbacks: Array from_account_id?: string | null from_end_user_id?: string | null from_source: string @@ -485,14 +453,13 @@ export type MessageDetailResponse = { inputs: { [key: string]: JsonValue } - message?: JsonValue | null - message_files?: Array - message_metadata_dict?: JsonValue | null - message_tokens?: number | null + message: JsonValue + message_files: Array + message_tokens: number + metadata: JsonValue parent_message_id?: string | null - provider_response_latency?: number | null + provider_response_latency: number query: string - re_sign_file_url_answer: string status: string workflow_run_id?: string | null } @@ -641,21 +608,10 @@ export type UserSatisfactionRateStatisticResponse = { data: Array } -export type TextToSpeechPayload = { - message_id?: string | null - streaming?: boolean | null - text: string - voice?: string | null -} - -export type AudioBinaryResponse = Blob | File - -export type TextToSpeechVoiceListResponse = Array<{ - [key: string]: unknown -}> +export type TextToSpeechVoiceListResponse = Array export type AppTraceResponse = { - enabled: boolean + enabled?: boolean tracing_provider?: string | null } @@ -731,7 +687,7 @@ export type WorkflowRunPaginationResponse = { export type WorkflowRunDetailResponse = { created_at?: number | null - created_by_account?: SimpleAccount | null + created_by_account?: SimpleAccountResponse | null created_by_end_user?: SimpleEndUser | null created_by_role?: string | null elapsed_time?: number | null @@ -799,7 +755,7 @@ export type WorkflowCommentCreate = { } export type WorkflowCommentMentionUsersPayload = { - users: Array + users: Array } export type WorkflowCommentDetail = { @@ -876,18 +832,10 @@ export type WorkflowPaginationResponse = { page: number } -export type DefaultBlockConfigsResponse = Array<{ - [key: string]: unknown -}> - -export type DefaultBlockConfigResponse = { - [key: string]: unknown -} - export type WorkflowResponse = { conversation_variables: Array created_at: number - created_by?: SimpleAccount | null + created_by?: SimpleAccountResponse | null environment_variables: Array features: { [key: string]: unknown @@ -902,7 +850,7 @@ export type WorkflowResponse = { rag_pipeline_variables: Array tool_published: boolean updated_at: number - updated_by?: SimpleAccount | null + updated_by?: SimpleAccountResponse | null version: string } @@ -923,13 +871,13 @@ export type SyncDraftWorkflowPayload = { } export type SyncDraftWorkflowResponse = { - hash?: string - result?: string - updated_at?: string + hash: string + result: string + updated_at: number } -export type WorkflowDraftVariableList = { - items?: Array +export type WorkflowDraftVariableListResponse = { + items: Array } export type ConversationVariableUpdatePayload = { @@ -938,8 +886,8 @@ export type ConversationVariableUpdatePayload = { }> } -export type EnvironmentVariableListResponse = { - items: Array +export type WorkflowDraftEnvironmentVariableListResponse = { + items: Array } export type EnvironmentVariableUpdatePayload = { @@ -961,7 +909,7 @@ export type HumanInputDeliveryTestPayload = { } } -export type EmptyObjectResponse = { +export type HumanInputDeliveryTestResponse = { [key: string]: unknown } @@ -1029,7 +977,7 @@ export type AgentComposerValidateResponse = { export type WorkflowRunNodeExecutionResponse = { created_at?: number | null - created_by_account?: SimpleAccount | null + created_by_account?: SimpleAccountResponse | null created_by_end_user?: SimpleEndUser | null created_by_role?: string | null elapsed_time?: number | null @@ -1062,17 +1010,6 @@ export type DraftWorkflowNodeRunPayload = { query?: string } -export type DraftWorkflowRunPayload = { - datasource_info_list: Array<{ - [key: string]: unknown - }> - datasource_type: string - inputs: { - [key: string]: unknown - } - start_node_id: string -} - export type WorkflowRunSnapshotView = { node_outputs?: Array workflow_run_id: string @@ -1099,56 +1036,36 @@ export type OutputPreviewView = { value?: unknown } -export type DraftWorkflowTriggerRunRequest = { - node_id: string -} - -export type DraftWorkflowTriggerRunAllPayload = { - node_ids: Array -} - -export type WorkflowDraftVariableListWithoutValue = { - items?: Array - total?: number +export type WorkflowDraftVariableListWithoutValueResponse = { + items: Array + total: number | null } -export type WorkflowDraftVariable = { - description?: string - edited?: boolean - full_content?: { - [key: string]: unknown - } - id?: string - is_truncated?: boolean - name?: string - selector?: Array - type?: string - value?: - | string - | number - | number - | boolean - | { - [key: string]: unknown - } - | Array - | null - value_type?: string - visible?: boolean +export type WorkflowDraftVariableResponse = { + description: string + edited: boolean + full_content: WorkflowDraftVariableFullContentResponse | null + id: string + is_truncated: boolean + name: string + selector: Array + type: string + value: unknown + value_type: string + visible: boolean } export type WorkflowDraftVariableUpdatePayload = { name?: string | null - value?: unknown | null + value?: unknown } export type PublishWorkflowPayload = { - knowledge_base_setting?: { - [key: string]: unknown - } | null + marked_comment?: string | null + marked_name?: string | null } -export type WorkflowPublishResponse = { +export type PublishWorkflowResponse = { created_at: number result: string } @@ -1167,12 +1084,6 @@ export type WorkflowUpdatePayload = { marked_name?: string | null } -export type WorkflowRestoreResponse = { - hash: string - result: string - updated_at: number -} - export type ApiKeyList = { data: Array } @@ -1223,12 +1134,30 @@ export type DeletedTool = { } export type ModelConfig = { - completion_params?: { - [key: string]: unknown - } - mode: LlmMode - name: string - provider: string + agent_mode?: unknown | null + annotation_reply?: unknown | null + chat_prompt_config?: unknown | null + completion_prompt_config?: unknown | null + created_at?: number | null + created_by?: string | null + dataset_configs?: unknown | null + dataset_query_variable?: string | null + external_data_tools?: unknown | null + file_upload?: unknown | null + model?: unknown | null + more_like_this?: unknown | null + opening_statement?: string | null + pre_prompt?: string | null + prompt_type?: string | null + retriever_resource?: unknown | null + sensitive_word_avoidance?: unknown | null + speech_to_text?: unknown | null + suggested_questions?: unknown | null + suggested_questions_after_answer?: unknown | null + text_to_speech?: unknown | null + updated_at?: number | null + updated_by?: string | null + user_input_form?: unknown | null } export type Site = { @@ -1254,17 +1183,6 @@ export type Tag = { type: string } -export type JsonValue - = | string - | number - | number - | boolean - | { - [key: string]: unknown - } - | Array - | null - export type WorkflowPartial = { created_at?: number | null created_by?: string | null @@ -1289,7 +1207,7 @@ export type WorkflowOnlineUsersByApp = { export type AdvancedChatWorkflowRunForListResponse = { conversation_id?: string | null created_at?: number | null - created_by_account?: SimpleAccount | null + created_by_account?: SimpleAccountResponse | null elapsed_time?: number | null exceptions_count?: number | null finished_at?: number | null @@ -1302,6 +1220,12 @@ export type AdvancedChatWorkflowRunForListResponse = { version?: string | null } +export type HumanInputUserActionResponse = { + button_style?: string + id: string + title: string +} + export type AgentDriveItemResponse = { created_at?: number | null file_kind: string @@ -1396,7 +1320,7 @@ export type CliToolSuggestion = { name: string } -export type AnnotationEmbeddingModelResponse = { +export type AnnotationSettingEmbeddingModelResponse = { embedding_model_name?: string | null embedding_provider_name?: string | null } @@ -1427,7 +1351,7 @@ export type ConversationWithSummary = { read_at?: number | null status: string status_count?: StatusCount | null - summary_or_query: string + summary: string updated_at?: number | null user_feedback_stats?: FeedbackStat | null } @@ -1441,13 +1365,13 @@ export type Conversation = { admin_feedback_stats?: FeedbackStat | null annotation?: ConversationAnnotation | null created_at?: number | null - first_message?: SimpleMessageDetail | null from_account_id?: string | null from_account_name?: string | null from_end_user_id?: string | null from_end_user_session_id?: string | null from_source: string id: string + message?: SimpleMessageDetail | null model_config?: SimpleModelConfig | null read_at?: number | null status: string @@ -1459,6 +1383,7 @@ export type MessageDetail = { agent_thoughts: Array annotation?: ConversationAnnotation | null annotation_hit_history?: ConversationAnnotationHitHistory | null + answer: string answer_tokens: number conversation_id: string created_at?: number | null @@ -1473,12 +1398,11 @@ export type MessageDetail = { } message: JsonValue message_files: Array - message_metadata_dict: JsonValue message_tokens: number + metadata: JsonValue parent_message_id?: string | null provider_response_latency: number query: string - re_sign_file_url_answer: string status: string workflow_run_id?: string | null } @@ -1498,7 +1422,6 @@ export type AgentThought = { created_at?: number | null files: Array id: string - message_chain_id?: string | null message_id: string observation?: string | null position: number @@ -1518,8 +1441,8 @@ export type ConversationAnnotation = { export type ConversationAnnotationHitHistory = { annotation_create_account?: SimpleAccount | null + annotation_id: string created_at?: number | null - id: string } export type HumanInputContent = { @@ -1538,6 +1461,8 @@ export type Feedback = { rating: string } +export type JsonValue = unknown + export type MessageFile = { belongs_to?: string | null filename: string @@ -1578,10 +1503,10 @@ export type DailyMessageStatisticItem = { } export type DailyTokenCostStatisticItem = { - currency: string + currency?: string | null date: string - token_count: number - total_price: string | number + token_count?: number | null + total_price?: string | null } export type TokensPerSecondStatisticItem = { @@ -1594,9 +1519,14 @@ export type UserSatisfactionRateStatisticItem = { rate: number } +export type TextToSpeechVoiceResponse = { + name: string + value: string +} + export type WorkflowAppLogPartialResponse = { created_at?: number | null - created_by_account?: SimpleAccount | null + created_by_account?: SimpleAccountResponse | null created_by_end_user?: SimpleEndUser | null created_by_role?: string | null created_from?: string | null @@ -1607,7 +1537,7 @@ export type WorkflowAppLogPartialResponse = { export type WorkflowArchivedLogPartialResponse = { created_at?: number | null - created_by_account?: SimpleAccount | null + created_by_account?: SimpleAccountResponse | null created_by_end_user?: SimpleEndUser | null id: string trigger_metadata?: unknown @@ -1616,7 +1546,7 @@ export type WorkflowArchivedLogPartialResponse = { export type WorkflowRunForListResponse = { created_at?: number | null - created_by_account?: SimpleAccount | null + created_by_account?: SimpleAccountResponse | null elapsed_time?: number | null exceptions_count?: number | null finished_at?: number | null @@ -1628,7 +1558,7 @@ export type WorkflowRunForListResponse = { version?: string | null } -export type SimpleAccount = { +export type SimpleAccountResponse = { email: string id: string name: string @@ -1671,8 +1601,9 @@ export type WorkflowCommentBasic = { updated_at?: number | null } -export type AccountWithRole = { +export type AccountWithRoleResponse = { avatar?: string | null + readonly avatar_url: string | null created_at?: number | null email: string id: string @@ -1760,7 +1691,7 @@ export type PipelineVariableResponse = { variable: string } -export type EnvironmentVariableItemResponse = { +export type WorkflowDraftEnvironmentVariableResponse = { description?: string | null editable: boolean edited: boolean @@ -1961,46 +1892,59 @@ export type NodeOutputStatus export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' -export type WorkflowDraftVariableWithoutValue = { - description?: string - edited?: boolean - id?: string - is_truncated?: boolean - name?: string - selector?: Array - type?: string - value_type?: string - visible?: boolean +export type WorkflowDraftVariableWithoutValueResponse = { + description: string + edited: boolean + id: string + is_truncated: boolean + name: string + selector: Array + type: string + value_type: string + visible: boolean +} + +export type WorkflowDraftVariableFullContentResponse = { + download_url: string + length: number | null + size_bytes: number | null + value_type: string } export type ModelConfigPartial = { created_at?: number | null created_by?: string | null - model?: JsonValue | null + model?: unknown | null pre_prompt?: string | null updated_at?: number | null updated_by?: string | null } -export type LlmMode = 'chat' | 'completion' - -export type Type = 'github' | 'marketplace' | 'package' +export type Type + = | 'app-selector' + | 'array[tools]' + | 'boolean' + | 'model-selector' + | 'secret-input' + | 'select' + | 'text-input' export type Github = { - github_plugin_unique_identifier: string - package: string + packages: string + release: string repo: string - version: string + repo_address: string } export type Marketplace = { - marketplace_plugin_unique_identifier: string - version?: string | null + organization: string + plugin: string + version: string } export type Package = { - plugin_unique_identifier: string - version?: string | null + manifest: PluginDeclaration + unique_identifier: string } export type WorkflowOnlineUser = { @@ -2034,7 +1978,7 @@ export type EnvSuggestion = { } export type SimpleModelConfig = { - model_dict?: JsonValue | null + model?: JsonValue | null pre_prompt?: string | null } @@ -2054,6 +1998,12 @@ export type SimpleMessageDetail = { query: string } +export type SimpleAccount = { + email: string + id: string + name: string +} + export type HumanInputFormDefinition = { actions?: Array display_in_ui?: boolean @@ -2303,6 +2253,30 @@ export type CheckResultView = { reason?: string | null } +export type PluginDeclaration = { + agent_strategy?: AgentStrategyProviderEntity | null + author: string | null + category: PluginCategory + created_at: string + datasource?: DatasourceProviderEntity | null + description: CoreToolsEntitiesCommonEntitiesI18nObject + endpoint?: EndpointProviderDeclaration | null + icon: string + icon_dark?: string | null + label: CoreToolsEntitiesCommonEntitiesI18nObject + meta: Meta + model?: ProviderEntity | null + name: string + plugins: Plugins + repo?: string | null + resource: PluginResourceRequirements + tags?: Array + tool?: ToolProviderEntity | null + trigger?: TriggerProviderEntity | null + verified?: boolean + version: string +} + export type UserActionConfig = { button_style?: ButtonStyle id: string @@ -2519,6 +2493,89 @@ export type AgentPermissionConfig = { export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown' +export type AgentStrategyProviderEntity = { + identity: AgentStrategyProviderIdentity + plugin_id?: string | null +} + +export type PluginCategory + = | 'agent-strategy' + | 'datasource' + | 'extension' + | 'model' + | 'tool' + | 'trigger' + +export type DatasourceProviderEntity = { + credentials_schema?: Array + identity: DatasourceProviderIdentity + oauth_schema?: OAuthSchema | null + provider_type: DatasourceProviderType +} + +export type CoreToolsEntitiesCommonEntitiesI18nObject = { + en_US: string + ja_JP?: string | null + pt_BR?: string | null + zh_Hans?: string | null +} + +export type EndpointProviderDeclaration = { + endpoints?: Array | null + settings?: Array +} + +export type Meta = { + minimum_dify_version?: string | null + version?: string | null +} + +export type ProviderEntity = { + background?: string | null + configurate_methods: Array + description?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + help?: ProviderHelpEntity | null + icon_small?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + icon_small_dark?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + model_credential_schema?: ModelCredentialSchema | null + models?: Array + position?: { + [key: string]: Array + } | null + provider: string + provider_credential_schema?: ProviderCredentialSchema | null + provider_name?: string + supported_model_types: Array +} + +export type Plugins = { + datasources?: Array | null + endpoints?: Array | null + models?: Array | null + tools?: Array | null + triggers?: Array | null +} + +export type PluginResourceRequirements = { + memory: number + permission?: Permission | null +} + +export type ToolProviderEntity = { + credentials_schema?: Array + identity: ToolProviderIdentity + oauth_schema?: OAuthSchema | null + plugin_id?: string | null +} + +export type TriggerProviderEntity = { + events?: Array + identity: TriggerProviderIdentity + subscription_constructor?: SubscriptionConstructor | null + subscription_schema?: Array +} + export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' export type ParagraphInputConfig = { @@ -2563,10 +2620,140 @@ export type AgentModelResponseFormatConfig = { [key: string]: unknown } -export type AgentSoulDifyToolCredentialRef = { - id?: string | null - provider?: string | null - type?: 'provider' | 'tool' +export type AgentSoulDifyToolCredentialRef = { + id?: string | null + provider?: string | null + type?: 'provider' | 'tool' +} + +export type AgentStrategyProviderIdentity = { + author: string + description: CoreToolsEntitiesCommonEntitiesI18nObject + icon: string + icon_dark?: string | null + label: CoreToolsEntitiesCommonEntitiesI18nObject + name: string + tags?: Array | null +} + +export type ProviderConfig = { + default?: number | string | number | boolean | null + help?: I18nObject | null + label?: I18nObject | null + multiple?: boolean + name: string + options?: Array